background-shape
Clean Architecture in PHP Without the Cult, A Pragmatic Take
October 5, 2023 · 7 min read · by Muhammad Amal programming

TL;DR — Clean Architecture is a direction, not a religion / The dependency rule is the only law that matters, the rest is taste / Stop at three layers in PHP unless you have a very good reason

There is a specific kind of PHP codebase that happens when a senior engineer reads Uncle Bob’s Clean Architecture on vacation and comes back convinced. Six months later you have App\Domain\User\UseCases\Commands\Handlers\Internal\UserRegistrationCommandHandlerImpl, and a junior dev quits because they cannot find where a feature lives. I have inherited two of those codebases and written one of them myself.

This post is what I would tell that earlier version of me. Clean Architecture has one real rule and a bunch of optional ones. Apply the rule, ignore the rest until you genuinely feel pain, and you will end up with a codebase that survives team turnover. Apply all of them upfront and you will spend more time on the architecture than the feature.

If you just finished the Laravel 10 upgrade and are wondering whether to use the momentum to “go clean,” read this first.

The Only Rule That Matters

Source code dependencies point inward. That is it. Your domain does not import your framework. Your use cases do not import HTTP. Your entities do not know what a database is.

Everything else — concentric circles, the four-layer diagram, the colour-coding — is pedagogical scaffolding. The rule is the rule.

In PHP terms, this means:

namespace App\Domain\Billing;

// FORBIDDEN inside this namespace:
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Symfony\Component\Routing\...;

// ALLOWED:
use App\Domain\Billing\Money;
use App\Domain\Shared\Clock;
use DateTimeImmutable;
use Webmozart\Assert\Assert;

The use statements at the top of a domain file are a smell test. If you see Laravel, Symfony, or PDO imports, the dependency rule is broken. That is the entire enforcement mechanism. You do not need a fancy linter; a grep will do.

# crude but effective
grep -r "use Illuminate" src/Domain/ && echo "VIOLATION"
grep -r "use Symfony"   src/Domain/ && echo "VIOLATION"

If you want it in CI, deptrac handles this declaratively. We added it to our pipeline as a non-blocking check first, then promoted it to blocking once the existing violations were paid down.

The Three-Layer PHP Stack

For a typical Laravel monolith, three layers is the sweet spot:

  1. Domain — entities, value objects, domain services, interfaces (ports)
  2. Application — use cases / command handlers / query handlers. Orchestration only.
  3. Infrastructure — Eloquent models, HTTP controllers, queues, mailers, external HTTP clients

That maps to a folder structure like:

src/
  Domain/
    Billing/
      Invoice.php
      InvoiceId.php
      Money.php
      InvoiceRepository.php      <-- interface
      Exceptions/
        InvoiceAlreadyPaid.php
  Application/
    Billing/
      MarkInvoicePaid/
        MarkInvoicePaidCommand.php
        MarkInvoicePaidHandler.php
  Infrastructure/
    Billing/
      EloquentInvoiceRepository.php
      Persistence/
        InvoiceEloquentModel.php

That is it. No UseCases\Commands\Handlers\Impl\Internal\Final\Real. Three folders.

The temptation to add a fourth layer (“Interfaces” or “Adapters” or “Presentation”) almost always comes from a Java book. In PHP, Laravel controllers and Symfony controllers are already adapters. They live in Infrastructure and that is the right place for them.

A Use Case That Actually Does Something

Let me show a real one. Billing edge case: a customer pays an invoice late, and we need to apply a late fee, mark it paid, and emit a domain event.

<?php

declare(strict_types=1);

namespace App\Domain\Billing;

use App\Domain\Shared\Clock;
use DateTimeImmutable;

final class Invoice
{
    private bool $paid = false;
    private ?DateTimeImmutable $paidAt = null;
    private Money $lateFee;

    public function __construct(
        private readonly InvoiceId $id,
        private readonly CustomerId $customerId,
        private Money $amount,
        private readonly DateTimeImmutable $dueAt,
    ) {
        $this->lateFee = Money::zero($amount->currency());
    }

    public function markPaid(Clock $clock): InvoicePaid
    {
        if ($this->paid) {
            throw new Exceptions\InvoiceAlreadyPaid($this->id);
        }

        $now = $clock->now();
        if ($now > $this->dueAt) {
            $this->lateFee = $this->amount->percent(5);
            $this->amount = $this->amount->add($this->lateFee);
        }

        $this->paid = true;
        $this->paidAt = $now;

        return new InvoicePaid($this->id, $this->amount, $this->lateFee, $now);
    }

    // getters omitted for brevity
}

Notice what is not in this file: no Carbon, no Model, no DB::, no Request. Just DateTimeImmutable (a PHP built-in), an injected Clock interface, and value objects from the same domain. This class is testable with new Invoice(...) and zero framework boot.

The handler that orchestrates it:

<?php

declare(strict_types=1);

namespace App\Application\Billing\MarkInvoicePaid;

use App\Domain\Billing\InvoiceRepository;
use App\Domain\Shared\Clock;
use App\Domain\Shared\EventBus;

final class MarkInvoicePaidHandler
{
    public function __construct(
        private readonly InvoiceRepository $invoices,
        private readonly Clock $clock,
        private readonly EventBus $events,
    ) {}

    public function __invoke(MarkInvoicePaidCommand $command): void
    {
        $invoice = $this->invoices->ofId($command->invoiceId);
        $event = $invoice->markPaid($this->clock);
        $this->invoices->save($invoice);
        $this->events->publish($event);
    }
}

Five lines of orchestration. Three injected ports. No business logic in the handler — that lives in Invoice::markPaid. This is the shape every use case should aspire to.

The Infrastructure Side

The Eloquent adapter is the boring part. This is where the framework lives, and it should be unapologetic about it:

<?php

declare(strict_types=1);

namespace App\Infrastructure\Billing;

use App\Domain\Billing\Invoice;
use App\Domain\Billing\InvoiceId;
use App\Domain\Billing\InvoiceRepository;
use App\Domain\Billing\Exceptions\InvoiceNotFound;
use App\Infrastructure\Billing\Persistence\InvoiceEloquentModel;

final class EloquentInvoiceRepository implements InvoiceRepository
{
    public function ofId(InvoiceId $id): Invoice
    {
        $row = InvoiceEloquentModel::query()->find($id->toString());
        if ($row === null) {
            throw new InvoiceNotFound($id);
        }
        return InvoiceMapper::toDomain($row);
    }

    public function save(Invoice $invoice): void
    {
        $row = InvoiceEloquentModel::query()->findOrNew($invoice->id()->toString());
        InvoiceMapper::hydrate($row, $invoice);
        $row->save();
    }
}

The mapper is the only piece allowed to know both worlds. Keep it dumb and tested.

The controller binding it all together:

public function pay(string $invoiceId, MarkInvoicePaidHandler $handler): JsonResponse
{
    $handler(new MarkInvoicePaidCommand(new InvoiceId($invoiceId)));
    return response()->json(['status' => 'ok']);
}

Laravel’s service container resolves the handler, which resolves the repository interface to its Eloquent implementation via a service provider binding. The controller has zero idea Eloquent exists.

Where I Stop and Why

I do not separate “entities” and “use cases” into different physical layers in PHP. Robert Martin’s book does. I do not, because the cost-benefit in a single-language monolith is bad. Java has cultural and historical reasons for the split; PHP does not.

I also do not write “request models” and “response models” distinct from command/query objects. The command IS the request model from the application’s perspective. Adding a RegisterUserRequestDto that becomes a RegisterUserCommand that becomes a RegisterUserHandlerInput is the kind of architecture that makes engineers quit. One object per concept.

I do not abstract the framework “just in case.” If you are using Laravel, use Laravel in your infrastructure layer. Use Mail::send, use Queue::push, use the cache facade. The domain is protected by the dependency rule; the infrastructure does not need to be portable to a framework you will never switch to.

Common Pitfalls

Things I have seen go wrong, in rough order of damage:

  • Anaemic domain models. Entities that are just getters and setters with all logic in “services”. The whole point of putting logic in the domain is that the entity enforces its own invariants. If your Invoice cannot tell you whether it is paid without a InvoiceStatusService, you have lost the plot.
  • Repository methods named after queries instead of intentions. findByStatusAndCustomerIdOrderedByCreatedAt is not a repository method; it is a SQL query in disguise. Use cases call repositories with intent: overdueInvoicesForCustomer($customerId).
  • Leaking ORM types. Returning Collection<Eloquent> from a domain interface. The domain has no idea Collection exists. Return array<Invoice> or a domain-defined InvoiceList.
  • Over-eager event sourcing. Clean Architecture does not require event sourcing. They are orthogonal. Adding ES at the same time as CA is two unknowns at once, and one will fail.
  • DTOs everywhere. A command IS a DTO. A query result IS a DTO. You do not need a separate Dto namespace; the type system already tells you what it is.
  • Mocks of concrete framework classes in domain tests. If you are mocking Carbon::now() in a domain test, your domain depends on Carbon. Inject a Clock instead and use FixedClock in tests.

The anaemic-domain problem is by far the most common in Laravel codebases, because Eloquent’s natural style — properties on a model, logic in services — is itself anaemic. Resist.

Wrapping Up

Clean Architecture in PHP is worth doing, but it is worth doing with restraint. One rule, three layers, no DTO factories, no acronym soup. The codebases I am proudest of look almost boring from a folder-tree perspective; the interesting work is inside the domain classes, where the business actually lives.

In the next post I will get more specific about hexagonal architecture — ports and adapters — and how it relates to what we covered here. Spoiler: it is mostly the same thing with different vocabulary, but the vocabulary matters when you are explaining it to a team. The original framing by Alistair Cockburn is worth reading directly if you want the history: see his hexagonal architecture article. I will dissect it next time.