Lesson 48 • Advanced
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.
What You'll Learn in This Lesson
- Split an app into domain, application and infrastructure layers
- Keep business logic independent of any framework
- Apply the five SOLID principles to real PHP classes
- Use dependency injection and a composition root instead of new
- Build service and repository layers behind interfaces
- Model data with DTOs and self-validating value objects
- Recognise the PSR standards (PSR-4/7/11/15) and why they matter
php file.php. The Output panel under each example shows exactly what to expect.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.
<?php
// A VALUE OBJECT wraps a primitive (here a string) so it can NEVER be invalid.
// Once constructed, an Email is guaranteed to be a real email - no defensive
// "is this a valid email?" checks scattered all over your app.
final class Email
{
// 'readonly' means the value can't change after construction (PHP 8.1+).
public function __construct(public readonly string $value)
{
// Guard clause: reject bad input at the boundary, not deep in the app.
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException("Invalid email: {$value}");
}
}
// Behaviour lives WITH the data - that's what makes it a value object.
public function domain(): string
{
return substr(strrchr($this->value, '@'), 1); // text after the '@'
}
}
$email = new Email('ada@example.com'); // valid -> object is created
echo "Address: {$email->value}\n"; // Address: ada@example.com
echo "Domain: {$email->domain()}\n"; // Domain: example.com
// Try a bad value and the object refuses to exist:
try {
new Email('not-an-email'); // throws before any object is returned
} catch (InvalidArgumentException $e) {
echo "Rejected: {$e->getMessage()}\n";
}Address: ada@example.com
Domain: example.com
Rejected: Invalid email: not-an-emailValidation 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.
<?php
// A REPOSITORY hides WHERE data lives behind an interface. Your business logic
// asks for a user by id; it never knows (or cares) if that's MySQL, an API,
// or an in-memory array. Swap the storage, the logic stays identical.
interface UserRepository
{
public function findById(int $id): ?string; // returns a name, or null
public function save(int $id, string $name): void;
}
// One concrete implementation - here an in-memory array stands in for a DB.
final class InMemoryUserRepository implements UserRepository
{
/** @var array<int, string> */
private array $users = [];
public function findById(int $id): ?string
{
return $this->users[$id] ?? null; // '??' = "or null if missing"
}
public function save(int $id, string $name): void
{
$this->users[$id] = $name;
}
}
$repo = new InMemoryUserRepository();
$repo->save(1, 'Ada Lovelace'); // persist a user
echo $repo->findById(1) . "\n"; // Ada Lovelace
echo ($repo->findById(99) ?? 'not found') . "\n"; // not foundAda Lovelace
not foundNotice 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.
<?php
// The SERVICE LAYER holds your USE CASES (one method = one thing a user can do).
// It DEPENDS ON INTERFACES it is handed in the constructor - that's
// DEPENDENCY INJECTION. The service never builds its own collaborators.
interface UserRepository { public function save(int $id, string $name): void; }
interface Mailer { public function send(string $to, string $body): void; }
final class InMemoryUserRepository implements UserRepository
{
public function save(int $id, string $name): void
{
echo "[DB] saved user #{$id} ({$name})\n";
}
}
final class ConsoleMailer implements Mailer
{
public function send(string $to, string $body): void
{
echo "[Mail] to {$to}: {$body}\n";
}
}
// Pure application logic. Notice: no 'new', no database driver, no mail client.
final class RegisterUser
{
public function __construct(
private UserRepository $users, // injected
private Mailer $mailer, // injected
) {}
public function handle(int $id, string $name, string $email): void
{
$this->users->save($id, $name); // domain action
$this->mailer->send($email, "Welcome, {$name}!"); // side effect
}
}
// COMPOSITION ROOT: the one place that wires real implementations together.
// In a framework a DI container does this for you from config.
$action = new RegisterUser(
new InMemoryUserRepository(),
new ConsoleMailer(),
);
$action->handle(1, 'Grace Hopper', 'grace@example.com');[DB] saved user #1 (Grace Hopper)
[Mail] to grace@example.com: Welcome, Grace Hopper!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-methodUserService. - O — Open/Closed: add behaviour by writing a new class, not by editing an existing one. (See the gateways below.)
- L — Liskov Substitution: any
PaymentGatewaymust 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.
<?php
// DEPENDENCY INVERSION (the 'D' in SOLID): PaymentService depends on the
// PaymentGateway INTERFACE, never on a concrete Stripe or PayPal class.
// Adding a gateway = one new class. PaymentService never changes (Open/Closed).
interface PaymentGateway
{
public function charge(int $cents, string $currency): string; // -> tx id
}
final class StripeGateway implements PaymentGateway
{
public function charge(int $cents, string $currency): string
{
return 'stripe_' . strtolower($currency) . '_' . $cents;
}
}
final class PayPalGateway implements PaymentGateway
{
public function charge(int $cents, string $currency): string
{
return 'paypal_' . strtolower($currency) . '_' . $cents;
}
}
final class PaymentService
{
// Constructor injection: the gateway is supplied from outside.
public function __construct(private PaymentGateway $gateway) {}
public function pay(int $cents, string $currency): string
{
$txId = $this->gateway->charge($cents, $currency);
echo "Paid {$cents} {$currency} -> tx {$txId}\n";
return $txId;
}
}
// Same service, two different gateways - chosen at wiring time, not in the logic.
(new PaymentService(new StripeGateway()))->pay(2999, 'USD');
(new PaymentService(new PayPalGateway()))->pay(4500, 'EUR');Paid 2999 USD -> tx stripe_usd_2999
Paid 4500 EUR -> tx paypal_eur_4500Now 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.
<?php
// 🎯 YOUR TURN — finish this DTO (Data Transfer Object).
// A DTO is a small, immutable, TYPED data bag that carries values between
// layers (e.g. controller -> service) so you never pass loose arrays around.
final class CreateOrderDto
{
public function __construct(
// 1) Give each property a TYPE and mark it 'readonly' so it can't change.
public readonly int $customerId, // 👉 keep this one as the pattern
public readonly ___ $product, // 👉 a product name is text: use string
public readonly ___ $quantity, // 👉 a quantity is a whole number: use int
) {}
}
$order = new CreateOrderDto(customerId: 7, product: 'Keyboard', quantity: 2);
echo "Customer #{$order->customerId}\n";
echo "Wants {$order->quantity} x {$order->product}\n";
// ✅ Expected output:
// Customer #7
// Wants 2 x KeyboardCustomer #7
Wants 2 x Keyboard___ with the right type (string for the product, int for the quantity), then run it. You should see the two expected lines.One more. Make the Greeter depend on the interface so any greeting style can be injected — that's Dependency Inversion in one line.
<?php
// 🎯 YOUR TURN — make this service depend on an INTERFACE, not a class.
// Right now Greeter is hard-wired to EnglishGreeting and can't be swapped.
// Type-hint the INTERFACE so any greeting style can be injected.
interface Greeting { public function text(string $name): string; }
final class EnglishGreeting implements Greeting
{
public function text(string $name): string { return "Hello, {$name}!"; }
}
final class Greeter
{
// 👉 Replace ___ with the INTERFACE name so any Greeting can be injected.
public function __construct(private ___ $greeting) {}
public function greet(string $name): void
{
echo $this->greeting->text($name) . "\n";
}
}
// Wiring: inject a concrete EnglishGreeting into the Greeter.
(new Greeter(new EnglishGreeting()))->greet('Ada');
// ✅ Expected output:
// Hello, Ada!Hello, Ada!___ with Greeting (the interface), so the concrete EnglishGreeting can be passed in. Output should be one greeting 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
requirestatements;App\\Domain\\Emaillives insrc/Domain/Email.php. - PSR-7 (HTTP Messages) — standard
RequestInterface/ResponseInterfaceso middleware works across frameworks. - PSR-11 (Container) — a
ContainerInterfacewithget()/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 thatimplementsit (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 theautoloadmap incomposer.jsonpoints 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
ifvalidation 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
Q: What is the difference between layered (clean) architecture and just using a framework?
A framework like Laravel or Symfony gives you HTTP routing, an ORM, queues and so on — the delivery mechanism. Clean (layered) architecture is about where your business rules live. You keep the domain layer (entities, value objects, business rules) and application layer (use-case services) free of any framework class, and put framework-specific code (controllers, Eloquent models, HTTP) in an outer infrastructure layer. The point of the dependency rule is that inner layers never import outer ones, so your core logic can be unit-tested in isolation and would survive a framework upgrade or even a framework swap.
Q: What are the PSR standards and which ones matter for architecture?
PSRs are PHP Standards Recommendations published by the PHP-FIG group so libraries from different vendors can interoperate. The ones that shape architecture are: PSR-4 (autoloading — map a namespace prefix to a directory so Composer loads classes on demand, no require statements), PSR-7 (HTTP message interfaces — RequestInterface and ResponseInterface, so middleware works across frameworks), PSR-11 (ContainerInterface — a standard get()/has() API every DI container implements), and PSR-15 (HTTP server handlers and middleware that operate on PSR-7 messages). Coding them against these interfaces, not concrete classes, is what makes parts swappable.
Q: What is dependency injection and why not just use 'new' inside a class?
Dependency injection means a class is given the collaborators it needs (usually through its constructor) instead of creating them with new. Calling new MysqlUserRepository() inside a service hard-wires that service to MySQL, so you can't substitute an in-memory fake in a test or switch databases without editing the class. Injecting a UserRepository interface inverts that: the service depends on an abstraction, and one central place — the composition root, or a PSR-11 container configured from config — decides which concrete class to supply. That is the Dependency Inversion principle in practice.
Q: What is the difference between a DTO and a value object?
Both are small, typically immutable objects, but they have different jobs. A DTO (Data Transfer Object) is a typed data bag that carries a set of values across a boundary — for example from a controller into a service — so you pass a typed object instead of a loose array. It usually has no behaviour and no validation beyond types. A value object models a concept in your domain (Email, Money, Coordinates), is defined by its value rather than an identity, validates itself in the constructor so it can never be invalid, and carries behaviour (Money::add, Email::domain). Rule of thumb: DTOs move data between layers; value objects encode domain rules.
Q: When should I split a service or repository into more classes?
Follow the Single Responsibility Principle: a class should have one reason to change. A repository that only persists and reads one aggregate is fine; if it starts formatting emails or calculating prices, those belong elsewhere. For services, a good pattern is one action class per use case (RegisterUser, PlaceOrder) rather than a giant UserService with twenty methods — small single-purpose classes are easier to test, inject and reason about. Split when a class grows a second responsibility, when its methods stop sharing the same dependencies, or when you find yourself needing only part of it in a test.
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.
<?php
// 🎯 MINI-CHALLENGE: a tiny layered "todo" feature.
// No code is filled in - work from the steps, then run it.
//
// 1. Define an interface TaskRepository with:
// public function add(string $title): void;
// public function all(): array;
// 2. Write InMemoryTaskRepository that stores titles in a private array.
// 3. Write a service AddTask whose constructor takes a TaskRepository
// (dependency injection) and a handle(string $title) method that
// calls $this->repo->add($title).
// 4. Composition root: create the repo, inject it into AddTask, add two tasks,
// then loop over $repo->all() and echo each one.
//
// Tip: keep the SERVICE free of 'new' - hand it the repo from outside.
//
// ✅ Expected output (example):
// - Write tests
// - Ship feature
// your code hereTaskRepository interface, an in-memory implementation, and an AddTask service that takes the repository via its constructor. Wire them in a composition root, add two tasks, and echo them.🎉 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
Sign up for free to track which lessons you've completed and get learning reminders.