Php Architecture
By the end of this lesson you'll be able to structure a real PHP application in clean layers — keeping your business rules independent of the framework — and apply SOLID, dependency injection, repositories, services, DTOs and the PSR standards that make code swappable and testable.
Part of the free Php course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
1️⃣ Layered (Clean) Architecture
Most messy PHP apps put everything in the controller: validation, business rules, SQL, email — all tangled together. Layered architecture (often called clean or hexagonal architecture) untangles this into rings that only point inwards. The golden rule is the dependency rule : inner layers must never know about outer ones.
- Domain (innermost) — your entities, value objects and pure business rules. No framework, no database code. This is the part that's uniquely yours .
- Application — use-case services (a.k.a. actions) that orchestrate the domain: "register a user", "place an order". They depend on interfaces, not concrete tools.
- Infrastructure (outermost) — the swappable details: controllers, the database, the mailer, third-party APIs. This layer implements the interfaces the inner layers define.
Because dependencies only point inward, your domain and application code never import a single framework class — which is exactly what lets you unit-test it in isolation and survive a framework upgrade. The next sections build each piece.
2️⃣ Value Objects (the Domain Layer)
A value object wraps a primitive value so it can never be invalid . Instead of passing a raw string around and re-checking "is this really an email?" everywhere, you build an Email that validates itself once, in its constructor. If construction succeeds, every later line can trust it completely. It's defined by its value (two Emails with the same string are equal), it's immutable ( readonly ), and it carries behaviour that belongs to the concept.
Validation happens once, at the boundary, with a guard clause . After that, an Email is a promise: it cannot hold rubbish. This is the heart of a rich domain layer — push rules into small types instead of scattering if checks across the app.
3️⃣ The Repository Layer
A repository is an interface that hides where data lives. Your business logic asks "give me user 1" or "save this user" and never learns whether that's MySQL, Redis, a REST API or an in-memory array. Because the logic depends on the UserRepository interface , you can swap the storage at any time — and in tests you swap in a fast in-memory fake with zero changes to the code under test.
Notice the interface speaks the domain's language ( findById , save ) — never SQL. The concrete InMemoryUserRepository lives in the infrastructure layer; the interface lives next to your domain. That's the dependency rule in action.
4️⃣ Services & Dependency Injection
The service (or action ) layer holds your use cases — one method per thing a user can do. Crucially, a service never builds its own collaborators with new ; it is handed them through its constructor. That's dependency injection (DI): the class declares the interfaces it needs and lets someone else supply concrete implementations.
The single place that picks the real implementations and wires them together is the composition root . In a framework, a DI container (anything implementing PSR-11's ContainerInterface ) reads your config and does this assembly for you.
Because RegisterUser contains no new and no framework imports, you can test it by passing fake implementations — and the framework never leaks into your business logic.
5️⃣ SOLID Applied to PHP
SOLID is five principles that keep classes small and swappable. In PHP they look like this:
- S — Single Responsibility: one class, one reason to change. One action class per use case ( RegisterUser ), not a 20-method UserService .
- O — Open/Closed: add behaviour by writing a new class, not by editing an existing one. (See the gateways below.)
- L — Liskov Substitution: any PaymentGateway must be usable wherever the interface is expected, without surprises.
- I — Interface Segregation: prefer small, focused interfaces ( Mailer , UserRepository ) over one fat one.
- D — Dependency Inversion: depend on abstractions, not concretions — type-hint interfaces and inject them.
Here's the 'D' (which pulls the others along). PaymentService depends on a PaymentGateway interface, so adding PayPal means writing one class — the service itself never changes.
Now you try. A DTO (Data Transfer Object) is a small, immutable, typed bag of values you pass between layers instead of a loose array. Fill in the two missing types using the 👉 hints, then run it and check it against the Output panel.
One more. Make the Greeter depend on the interface so any greeting style can be injected — that's Dependency Inversion in one line.
6️⃣ PSR Standards That Hold It Together
PSRs are PHP Standards Recommendations from the PHP-FIG group — shared interfaces so libraries from different vendors fit together. Four matter most for architecture:
- PSR-4 (Autoloading) — maps a namespace prefix to a folder so Composer loads classes on demand. No more require statements; App\\Domain\\Email lives in src/Domain/Email.php .
- PSR-7 (HTTP Messages) — standard RequestInterface / ResponseInterface so middleware works across frameworks.
- PSR-11 (Container) — a ContainerInterface with get() / has() that every DI container implements.
- PSR-15 (HTTP Handlers) — server request handlers and middleware that operate on PSR-7 messages — the modern request pipeline.
Coding against these interfaces — rather than a specific framework's classes — is what keeps your outer layer swappable. It's the same idea as your repositories and services, applied to the whole HTTP stack.
Common Errors (and the fix)
- "Cannot instantiate interface UserRepository" — you tried new UserRepository() . You can't instantiate an interface; instantiate a concrete class that implements it (e.g. new InMemoryUserRepository() ) and inject that .
- Your service is impossible to unit-test — it calls new MysqlRepository() internally, so tests need a real database. Inject the interface through the constructor instead; then a test can pass a fake.
- "Class 'App\\Domain\\Email' not found" — your namespace and folder don't match PSR-4, or you forgot composer dump-autoload . Check the autoload map in composer.json points the prefix at the right directory.
- "Cannot modify readonly property" — you tried to change a value object or DTO after construction. That's by design: build a new instance with the changed value rather than mutating the old one.
- Business logic breaks when you change the database — your domain/application layer imported a framework or ORM class directly. Move that dependency behind an interface so the inner layer never names the concrete tool.
Pro Tips
- 💡 Start with a modular monolith, not microservices. Clean layers and modules inside one deployable app give you 90% of the benefit with a fraction of the operational pain — extract a service only when a module truly needs independent scaling.
- 💡 Push rules into value objects. If you find the same if validation in three places, it probably wants to be a small self-validating type.
- 💡 One action class per use case. RegisterUser , PlaceOrder — small, single-purpose, trivially testable beats a god-object service.
- 💡 Write down architecture decisions in short ADR (Architecture Decision Record) notes so your future self knows why you chose a modular monolith over microservices.
📋 Quick Reference — Architecture Building Blocks
Concept
Lives in layer
What it's for
Value Object
Domain
Self-validating, immutable typed value (Email, Money)
Repository
Domain (interface) / Infra (impl)
Hide where data is stored behind an interface
Service / Action
Application
One use case; orchestrates the domain
DTO
Crosses boundaries
Typed data bag passed between layers
DI / Container
Composition root
Supply interfaces' concrete implementations (PSR-11)
PSR-4/7/11/15
Infrastructure
Autoload, HTTP messages, container, middleware
Frequently Asked Questions
Mini-Challenge: Layer a Todo Feature
No code is filled in this time — just a brief and an outline. Build the interface, the implementation, the injected service and the wiring yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is exactly the layering you'll repeat for every real feature.
🎉 Lesson Complete!
- ✅ Clean architecture splits an app into domain , application and infrastructure layers — dependencies only point inward
- ✅ Value objects validate once so a value can never be invalid; DTOs carry typed data between layers
- ✅ Repositories hide where data lives; services/actions hold one use case each
- ✅ Dependency injection hands a class its collaborators; the composition root (or a PSR-11 container) wires them up
- ✅ SOLID keeps classes small and swappable — depend on abstractions, extend by adding classes
- ✅ PSR-4/7/11/15 standardise autoloading, HTTP messages, containers and middleware so parts interoperate
- ✅ Next: bring it all together in the Final Project — a complete real-world PHP application
Practice quiz
In layered (clean) architecture, what is the dependency rule?
- Outer layers must never depend on inner ones
- Every layer depends on every other
- Inner layers must never know about outer ones — dependencies only point inward
- The database layer is the innermost
Answer: Inner layers must never know about outer ones — dependencies only point inward. Dependencies point inward only, so the domain and application layers never import framework classes and can be tested in isolation.
What is a value object such as the Email class in the lesson?
- A small immutable type that validates itself so it can never hold an invalid value
- A loose array of values
- A database row
- A controller method
Answer: A small immutable type that validates itself so it can never hold an invalid value. A value object wraps a primitive and validates in its constructor, so once built it's a promise the value can't be rubbish.
What is the main purpose of a repository?
- To render HTML templates
- To send emails
- To compile PHP to bytecode
- To hide WHERE data lives behind an interface so business logic doesn't know the storage
Answer: To hide WHERE data lives behind an interface so business logic doesn't know the storage. A repository exposes a domain-language interface (findById, save) so you can swap MySQL, an API, or an in-memory fake without changing the logic.
What does dependency injection mean for a service class?
- It builds its own collaborators with new
- It is handed its collaborators (usually via the constructor) instead of creating them
- It injects SQL into queries
- It loads classes via require
Answer: It is handed its collaborators (usually via the constructor) instead of creating them. With DI a class declares the interfaces it needs and someone else supplies the concrete implementations — no 'new' inside the service.
Where are the real implementations wired together?
- In the composition root (in a framework, a PSR-11 DI container)
- Inside each value object
- In the database schema
- In the repository interface
Answer: In the composition root (in a framework, a PSR-11 DI container). The composition root is the one place that picks concrete implementations and assembles them; a PSR-11 container does this from config.
What is the 'D' in SOLID, Dependency Inversion, in practice?
- Depend on concrete classes for speed
- Duplicate logic across classes
- Depend on abstractions (interfaces) and inject them, not on concretions
- Delete unused dependencies
Answer: Depend on abstractions (interfaces) and inject them, not on concretions. Type-hint and inject interfaces (like PaymentGateway) so adding a new implementation is one class and the consuming service never changes.
How does a DTO differ from a value object?
- They are the same thing
- A DTO carries typed data across a boundary; a value object encodes domain rules and validates itself
- A DTO always has behaviour and a value object never does
- A value object crosses layers; a DTO models a domain concept
Answer: A DTO carries typed data across a boundary; a value object encodes domain rules and validates itself. DTOs are typed data bags that move data between layers; value objects (Email, Money) model a concept, validate, and carry behaviour.
What does PSR-4 standardise?
- HTTP message interfaces
- The DI container API
- Middleware handlers
- Autoloading — mapping a namespace prefix to a directory so Composer loads classes on demand
Answer: Autoloading — mapping a namespace prefix to a directory so Composer loads classes on demand. PSR-4 maps a namespace prefix to a folder, so App\Domain\Email lives in src/Domain/Email.php with no require statements.
Which PSR defines the ContainerInterface with get() and has()?
- PSR-4
- PSR-11
- PSR-7
- PSR-15
Answer: PSR-11. PSR-11 is the ContainerInterface that every DI container implements; PSR-7 is HTTP messages and PSR-15 is HTTP handlers/middleware.
What error do you get from trying to instantiate an interface directly?
- Class not found
- Cannot modify readonly property
- Cannot instantiate interface — instantiate a concrete class that implements it instead
- Nothing, it works fine
Answer: Cannot instantiate interface — instantiate a concrete class that implements it instead. You cannot do new UserRepository() on an interface; create a concrete class like InMemoryUserRepository and inject that.
Continue this course
- Previous: Deployment
- Next: Final Project