- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: System Design Pocket Guide: Fundamentals
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a fresh Laravel project. The controller calls a repository. The repository hydrates an entity. The entity has a method or two. Three layers, total. The feature ships on Tuesday and nobody on the team is unhappy.
Six months later the same controller has a sibling: a CLI command that runs the same flow at 3 a.m. Then a queue worker picks up the same flow from a webhook. The controller, the command, and the worker all do the same five things (load the customer, validate, compute, save, dispatch), and they all do them slightly differently. The tests for each entry point repeat the same setup. A small change to the order rules touches three files in three different parts of the codebase.
That's the moment the missing fourth layer starts charging interest.
The two shapes, side by side
The three-layer shape is the Laravel/Symfony default that ships in every starter:
HTTP Controller ──▶ Repository ──▶ Entity (DB row)
The four-layer shape inserts a Use Case between the controller and the repository:
HTTP Controller ──▶ Use Case ──▶ Repository ──▶ Entity
CLI Command ──▶ Use Case ──▶ Repository ──▶ Entity
Queue Worker ──▶ Use Case ──▶ Repository ──▶ Entity
The Use Case (Clean Architecture vocabulary; "application service" in DDD-flavored books; "interactor" in Robert C. Martin's original) is one class with one public method that owns the business operation. The controller becomes a translator: HTTP in, DTO out. The repository goes back to being a thin persistence wrapper.
The shapes disagree about who is allowed to call what, not where the code goes. In a three-layer service the controller is the orchestrator. In a four-layer service the controller is an adapter, and the use case is the orchestrator.
Three layers in PHP, no apologies
For a service with one HTTP entry point and a straight read/write per request, three layers is the right answer. Here is what it looks like with declare(strict_types=1) and a recent Laravel container.
<?php
declare(strict_types=1);
final class OrderController
{
public function __construct(
private OrderRepository $orders,
private Clock $clock,
) {}
public function store(StoreOrderRequest $req): JsonResponse
{
$items = array_map(
static fn (array $i) => new Item(
sku: $i['sku'],
quantity: $i['quantity'],
priceCents: $i['price_cents'],
),
$req->validated()['items'],
);
$order = new Order(
id: Order::newId(),
customerId: $req->customerId(),
items: $items,
totalCents: Order::totalFor($items),
createdAt: $this->clock->now(),
);
$this->orders->save($order);
return new JsonResponse($order, 201);
}
}
That is the whole pipeline. The controller hydrates from the request, calls a domain helper for the math, asks the repository to save. No orchestration class in the middle, because nothing in the middle is being shared.
This shape is honest when:
- There is exactly one entry point (HTTP, or only CLI, or only worker).
- The operation is one transactional unit: a single
INSERTor a singleUPDATE, no follow-on side effects that need to succeed together. - The validation rules belong to the input shape (
StoreOrderRequestchecks them), not to a cross-cutting business invariant.
Adding a use case here would mean a class named CreateOrderUseCase that takes the same arguments, calls the same repository, returns the same DTO, and forces every test to mock one more boundary. The ceremony costs more than it returns. Skip it.
When the third entry point arrives
The story changes when a second caller shows up. A nightly cron now needs to backfill orders from a partner CSV. The 3 a.m. command can't go through HTTP — there is no request, no StoreOrderRequest, no JsonResponse. The original instinct is to extract a private method:
private function createOrder(string $customerId, array $items): Order
{
// copy-pasted body from the controller
}
And then call that from a console command. Six months later a third caller (a Stripe webhook worker) does the same dance. Now the "private method" lives somewhere awkward. It is either still on the controller (which the worker has to instantiate), or on a base class that everything extends, or on a "service" with a name like OrderService. That class became a junk drawer for anything the team couldn't place.
The use case is the named, deliberate version of the same extraction. One class, one method, the orchestration of the operation, decoupled from how it was triggered.
<?php
declare(strict_types=1);
final readonly class CreateOrder
{
public function __construct(
private OrderRepository $orders,
private CustomerRepository $customers,
private EventDispatcher $events,
private Clock $clock,
) {}
public function execute(CreateOrderInput $in): Order
{
$customer = $this->customers->byId($in->customerId)
?? throw new CustomerNotFound($in->customerId);
if (!$customer->canPurchase()) {
throw new CustomerBlocked($in->customerId);
}
$order = new Order(
id: Order::newId(),
customerId: $customer->id,
items: $in->items,
totalCents: Order::totalFor($in->items),
createdAt: $this->clock->now(),
);
$this->orders->save($order);
$this->events->dispatch(new OrderCreated($order->id));
return $order;
}
}
CreateOrderInput is a plain DTO with public readonly fields, no validation logic, no framework binding. The HTTP controller builds one from StoreOrderRequest. The CLI builds one from CSV rows. The webhook worker builds one from the Stripe payload. Three entry points, one operation, one place to change the rules.
The controller shrinks to a translator:
public function store(StoreOrderRequest $req, CreateOrder $createOrder): JsonResponse
{
$input = CreateOrderInput::fromRequest($req);
$order = $createOrder->execute($input);
return new JsonResponse($order, 201);
}
The CLI is the same shape with a different translator:
protected function handle(CreateOrder $createOrder): int
{
foreach ($this->readCsv($this->argument('file')) as $row) {
$createOrder->execute(CreateOrderInput::fromCsvRow($row));
}
return self::SUCCESS;
}
The worker, the same. Each adapter owns its transport. The business operation lives in one place.
What you actually gain (and what you don't)
The four-layer shape is not free. You add one class per operation, one DTO per operation, one set of unit tests for the use case, and a small amount of wiring in the container. For a 5-endpoint CRUD admin, that overhead is real and unhelpful.
The payback shows up when one of these is true:
Multiple entry points hit the same operation. HTTP + CLI + queue worker is the canonical trio. Two of the three is enough to justify the extraction. One is not.
The operation spans multiple writes that must succeed together. Save the order, dispatch the event, decrement the inventory, log the audit row. A controller doing all four with DB::transaction(function () { ... }) works until the second entry point arrives. At that point the transaction boundary moves with the operation, not with the request. The use case owns the boundary.
public function execute(CreateOrderInput $in): Order
{
return $this->tx->run(function () use ($in): Order {
$order = $this->build($in);
$this->orders->save($order);
$this->inventory->reserve($order);
$this->audit->record($order);
$this->events->dispatch(new OrderCreated($order->id));
return $order;
});
}
The transaction belongs to the operation's contract. The controller stays out of it.
The operation has a name the product owner uses. "Create order", "Approve refund", "Suspend account", "Resume subscription". When the operation has a name in the product spec, giving it a class with that name pays off in code review and onboarding. New engineers grep for the verb and find the file.
The rules will outlive the framework. When you can imagine the same operation moving from Laravel 11 to Symfony 7, or from a monolith to a queue worker in its own deployable, the use case is the unit you carry. Controllers and repositories get rewritten per framework.
You do not gain testability automatically. You can test a three-layer service well with a repository fake. You can test a four-layer service badly with too many mocks. The win is structural. Once the use case is its own class, a test instantiates it with fake collaborators and runs without the framework container.
The decision table
| Signal | Three layers is fine | Four layers earns its keep |
|---|---|---|
| Entry points to the operation | One | Two or more |
| Writes per operation | One | Multiple, must commit together |
| Operation name in the spec | "Endpoint X" | A verb the product owner says aloud |
| Side effects (events, audits, external calls) | None or one | Two or more, ordered |
| Validation surface | Input shape only | Cross-cutting business rules |
| Expected lifespan | Months | Years across framework upgrades |
| Team size touching this code | One or two devs | A rotation of devs over time |
Two or more rows pointing right? Add the use case. All rows pointing left? Don't.
A middle path: extract on the second caller, not the first
The four-layer shape doesn't have to be a day-one decision. The honest pattern in PHP teams that ship well:
- Ship the three-layer version. Controller calls repository calls entity. Done in an afternoon.
- When the second caller arrives, extract the use case. Move the orchestration out of the controller into a named class. The controller becomes a translator; the new caller gets the same translator pattern.
- When the third caller arrives, the cost is zero. There is already a use case to call.
The cost of waiting is one refactor at step 2. The cost of going four-layer on day one for a service that never gets a second caller is permanent overhead. The trade is real.
The thing to watch for is the version of step 1 that becomes load-bearing. If the controller's private method has been copy-pasted into a console command and the team is now editing both copies whenever the rules change, you skipped step 2. Catch yourself.
Where the application layer ends and the domain layer begins
A common confusion: people read about "the application layer" and start putting business rules in the use case. That is a wrong turn. The use case orchestrates. The entity (or a domain service when the rule doesn't fit on an entity) decides.
final readonly class Order
{
public static function totalFor(array $items): int
{
return array_reduce(
$items,
static fn (int $sum, Item $i) => $sum + $i->priceCents * $i->quantity,
0,
);
}
}
totalFor is a domain rule. It lives on Order, not in CreateOrder. The use case calls it. If you find your use case computing totals, validating SKUs, or applying discount logic, those rules want to move down a layer. The use case stays a thin sequencer: load, decide via the domain, save, dispatch.
That separation is what lets the four-layer shape survive. The day you migrate from Laravel to Symfony, the domain and the use cases come with you. The controllers and the repositories are rewritten. If business rules were spread across the controller, the use case, and the entity, the migration would be a year of work. Concentrated in the domain, it is a weekend.
Verdict
Add the fourth layer the moment a second entry point appears, or the moment the operation grows a second write that must commit with the first. Name the class after the verb the product owner uses. Keep the orchestration in the use case and the rules in the domain. Picking the wrong shape for the scope costs you either ceremony you didn't need or refactors you should have avoided.
If this was useful
The four-layer pattern is the spine of Decoupled PHP — use cases, ports, adapters, and the migration playbook for taking a framework-coupled Laravel or Symfony service to a shape that survives the next framework upgrade. The book walks the same operation from a one-entry-point CRUD up through HTTP + CLI + queue with transactional orchestration, in production-grade PHP 8.3.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)