Skip to main content
    Courses/PHP/PHP Architecture

    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

    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.

    A self-validating value object
    <?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";
    }
    Output
    Address: ada@example.com
    Domain:  example.com
    Rejected: Invalid email: not-an-email
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    A repository behind an interface
    <?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 found
    Output
    Ada Lovelace
    not found
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    A use-case service with constructor injection
    <?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');
    Output
    [DB] saved user #1 (Grace Hopper)
    [Mail] to grace@example.com: Welcome, Grace Hopper!
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    Dependency Inversion in PHP
    <?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');
    Output
    Paid 2999 USD -> tx stripe_usd_2999
    Paid 4500 EUR -> tx paypal_eur_4500
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    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.

    🎯 Your turn: finish a typed DTO
    <?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 Keyboard
    Output
    Customer #7
    Wants 2 x Keyboard
    Replace each ___ 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.

    🎯 Your turn: inject an interface
    <?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!
    Output
    Hello, Ada!
    Replace the ___ 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 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

    ConceptLives in layerWhat it's for
    Value ObjectDomainSelf-validating, immutable typed value (Email, Money)
    RepositoryDomain (interface) / Infra (impl)Hide where data is stored behind an interface
    Service / ActionApplicationOne use case; orchestrates the domain
    DTOCrosses boundariesTyped data bag passed between layers
    DI / ContainerComposition rootSupply interfaces' concrete implementations (PSR-11)
    PSR-4/7/11/15InfrastructureAutoload, 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.

    🎯 Mini-Challenge: a tiny layered todo 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 here
    Define a TaskRepository 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service