- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You join a PHP codebase on a Monday. You open the project and the first thing you see is a Laravel Http/Controllers/ folder with eighty files. Each one is three hundred lines. There are Eloquent calls, queue dispatches, mail sends, validation rules, and somewhere in the middle of it all the actual business decision that makes the company money. Two screens of scrolling to find one if.
Six months later, the team says they want to try Symfony. Or they want to extract one piece into a Slim micro-service. Or Laravel 12 changes a contract and you have to touch every controller. The estimate comes back: three months. Maybe four. Because everything is in the controllers, and the controllers belong to the framework.
This is the part where people say "frameworks are not the enemy" and you nod and nothing changes. The framework is not the enemy. The framework is also not the asset. It is the outer ring (the replaceable layer), and the only reason you can replace it is because nothing important lives there.
The rings, from the inside out
If you have read anything about hexagonal architecture, clean architecture, or onion architecture, you have seen the same picture. A target, a hexagon, an onion. Different shapes, same idea.
In the middle you have your domain. The rules that make the business work. An Order cannot have a negative total. A Subscription cannot be Resumed if it was never Paused. A Wallet cannot be debited below zero unless an overdraft is approved. These rules are true on Laravel, on Symfony, on Slim, in a CLI script, on the moon. They are the thing your company would pay you to write again if the codebase burned down tomorrow.
Around that, the use cases (sometimes called application services). They orchestrate the domain. "Submit a cart" is a use case. It loads things, calls domain methods, and persists the result. The use case does not know HTTP exists. It does not know Eloquent exists. It takes a plain input object and returns a plain output object.
Then ports — the interfaces the use case needs. OrderRepository, Clock, Mailer, EventBus. The use case names what it needs; it does not name how.
Then, finally, the outer ring. Controllers. ORMs. Queue workers. Mail drivers. HTTP clients. Console commands. Cron handlers. Webhook receivers. Every one of these is an adapter. It implements a port, calls a use case, or both. No business rule lives here. Nothing in this ring knows what an Order is supposed to mean.
The outer ring is replaceable on purpose. That is the design. If a controller carried a business rule, you would lose the rule when you swapped controllers. So the rule lives one ring further in, where no controller can touch it.
What lives in the outer ring (and why none of it matters to the business)
Open any "real" PHP application and the outer ring usually contains:
- HTTP controllers — parse a request, call a use case, format a response.
- Console commands — parse argv, call a use case, print output.
- Queue workers / jobs — pull a message off a queue, call a use case, ack the message.
- ORM models / repositories — translate between domain objects and rows.
-
Mailers — turn a
Notificationvalue object into an SMTP call. -
HTTP clients — wrap a
GuzzleHttp\Clientbehind an interface the use case owns. - Validation rules — turn raw input into a use-case input DTO and reject the rest.
-
Auth middleware — turn a session/token into a
UserId. - Event listeners / subscribers — translate framework events into domain events or vice versa.
Look at that list. Pick any one of those. Ask yourself: if the business changed nothing about its rules, but I rewrote this in a different framework, would the company notice? For every single one, the answer is no. The customer does not care that you switched from \Illuminate\Http\Request to \Symfony\Component\HttpFoundation\Request. They care that the order they placed has the right total and shows up in their email.
The outer ring is the part of your codebase the customer cannot see. Make it the part you can rewrite cheaply.
A worked example: one use case, three frameworks
Here is the trick that makes the abstract idea concrete. We write one use case, in plain PHP 8.3, and then we wrap it in three different framework adapters. Laravel, Symfony, Slim. Same domain. Same persistence interface. Different outer rings. The middle does not move.
The domain and the use case (framework-free)
<?php
declare(strict_types=1);
namespace App\Domain\Checkout;
final class Cart
{
/** @param list<LineItem> $items */
public function __construct(
public readonly string $customerId,
public readonly array $items,
) {}
public function totalCents(): int
{
$total = 0;
foreach ($this->items as $item) {
$total += $item->priceCents * $item->quantity;
}
return $total;
}
}
final class LineItem
{
public function __construct(
public readonly string $sku,
public readonly int $quantity,
public readonly int $priceCents,
) {}
}
final class Order
{
public function __construct(
public readonly string $id,
public readonly string $customerId,
public readonly int $totalCents,
public readonly \DateTimeImmutable $createdAt,
) {}
}
No framework imports. No use Illuminate\..., no use Symfony\.... The domain is portable.
The port — what the use case needs from persistence and the clock:
<?php
declare(strict_types=1);
namespace App\Application\Checkout;
use App\Domain\Checkout\Order;
interface OrderRepository
{
public function save(Order $order): void;
}
interface Clock
{
public function now(): \DateTimeImmutable;
}
interface IdGenerator
{
public function newId(): string;
}
The use case itself, also framework-free:
<?php
declare(strict_types=1);
namespace App\Application\Checkout;
use App\Domain\Checkout\Cart;
use App\Domain\Checkout\Order;
final class SubmitCheckout
{
public function __construct(
private readonly OrderRepository $orders,
private readonly Clock $clock,
private readonly IdGenerator $ids,
) {}
public function __invoke(Cart $cart): Order
{
$order = new Order(
id: $this->ids->newId(),
customerId: $cart->customerId,
totalCents: $cart->totalCents(),
createdAt: $this->clock->now(),
);
$this->orders->save($order);
return $order;
}
}
Under twenty lines of business logic. No HTTP. No SQL. No queue. No framework. If I delete every controller, every router, every ORM model — this code still runs and still does the right thing.
Laravel adapter
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Application\Checkout\SubmitCheckout;
use App\Domain\Checkout\Cart;
use App\Domain\Checkout\LineItem;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class CheckoutController
{
public function __construct(
private readonly SubmitCheckout $submit,
) {}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'customer_id' => 'required|string',
'items' => 'required|array|min:1',
'items.*.sku' => 'required|string',
'items.*.quantity' => 'required|integer|min:1',
'items.*.price_cents' => 'required|integer|min:0',
]);
$cart = new Cart(
customerId: $data['customer_id'],
items: array_map(
fn(array $i) => new LineItem(
$i['sku'], $i['quantity'], $i['price_cents']
),
$data['items'],
),
);
$order = ($this->submit)($cart);
return new JsonResponse([
'id' => $order->id,
'total_cents' => $order->totalCents,
], 201);
}
}
The controller parses, builds the input, calls the use case, formats the response. That is its whole job.
Symfony adapter
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Application\Checkout\SubmitCheckout;
use App\Domain\Checkout\Cart;
use App\Domain\Checkout\LineItem;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
final class CheckoutController
{
public function __construct(
private readonly SubmitCheckout $submit,
) {}
#[Route('/checkout', methods: ['POST'])]
public function store(Request $request): JsonResponse
{
$payload = json_decode(
$request->getContent(), true, flags: JSON_THROW_ON_ERROR
);
$cart = new Cart(
customerId: $payload['customer_id'],
items: array_map(
fn(array $i) => new LineItem(
$i['sku'], $i['quantity'], $i['price_cents']
),
$payload['items'],
),
);
$order = ($this->submit)($cart);
return new JsonResponse([
'id' => $order->id,
'total_cents' => $order->totalCents,
], 201);
}
}
Different request object, different response object, different routing. Same use case. Same domain. Notice that nothing about SubmitCheckout, Cart, Order, or LineItem changed between the two versions. Only the wrapping changed.
Slim adapter
<?php
declare(strict_types=1);
use App\Application\Checkout\SubmitCheckout;
use App\Domain\Checkout\Cart;
use App\Domain\Checkout\LineItem;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Slim\Factory\AppFactory;
$app = AppFactory::create();
$app->post('/checkout', function (Request $request, Response $response) {
$payload = json_decode((string) $request->getBody(), true);
$cart = new Cart(
customerId: $payload['customer_id'],
items: array_map(
fn(array $i) => new LineItem(
$i['sku'], $i['quantity'], $i['price_cents']
),
$payload['items'],
),
);
/** @var SubmitCheckout $submit */
$submit = $this->get(SubmitCheckout::class);
$order = $submit($cart);
$response->getBody()->write(json_encode([
'id' => $order->id,
'total_cents' => $order->totalCents,
]));
return $response
->withHeader('Content-Type', 'application/json')
->withStatus(201);
});
$app->run();
Slim is a micro-framework, so the wiring is more compact. The shape is identical: parse, build the cart, call the use case, format the response.
Three frameworks. One business rule. The migration cost between any two of these is one adapter file. Nothing else moves.
"But the ORM is so convenient"
Yes. Eloquent and Doctrine save you time on the boring parts. That is a feature, not a problem. The problem is when an Eloquent model also carries business rules — when Order::confirm() is a method on the model and it knows about the database connection and the event dispatcher and the mail facade all at once.
The fix is not "stop using Eloquent." The fix is to keep Eloquent in the outer ring, used by an adapter that implements your OrderRepository. The adapter maps between the framework model and the domain object. The domain object is a plain PHP class.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Eloquent;
use App\Application\Checkout\OrderRepository;
use App\Domain\Checkout\Order as DomainOrder;
final class EloquentOrderRepository implements OrderRepository
{
public function save(DomainOrder $order): void
{
OrderModel::create([
'id' => $order->id,
'customer_id' => $order->customerId,
'total_cents' => $order->totalCents,
'created_at' => $order->createdAt,
]);
}
}
Now Eloquent is doing what it is good at — talking to the database — and the domain has no idea it exists. Swap to Doctrine, and only EloquentOrderRepository becomes DoctrineOrderRepository. Everything else stands.
What this buys you on a normal Tuesday
-
Tests run in milliseconds. Use cases get a fake
OrderRepositoryand a fixedClock. No database, no HTTP server, no queue. The test file is twenty lines. -
Code review gets faster. Reviewers can read the use case without paging in framework conventions. The business decision is on the screen, not buried under
$request->validated()calls. - Onboarding gets faster. A new engineer learns the domain in one folder. They learn the framework when they need to.
- Framework upgrades stop being events. Laravel 11 to 12 touches the outer ring. The domain does not move. The blast radius is measurable.
- Queue handlers and HTTP handlers share logic for free. Same use case, called from a different adapter. No duplication.
Enforce the rule "domain imports nothing infrastructural" for six months and these become the normal weekly experience.
Where to start in an existing codebase
You are probably not greenfield. Most PHP shops are not. You can introduce the outer ring without a rewrite:
- Pick one controller method. Something with real business logic in it — not a list endpoint, an action endpoint. Submit a payment, confirm an order, cancel a subscription.
-
Extract the body of the method into an invokable class. Move it under
App\Application\<Feature>\. Inject it into the controller. -
Find the framework things it touches —
Auth::user(),Mail::send(), the ORM model. Replace each with an interface that the use case owns. The interface lives next to the use case; the implementation lives inApp\Infrastructure\. - Write a unit test for the use case using fakes. If you cannot write one in under ten minutes, the use case is still coupled to the framework — keep refactoring until you can.
- Repeat for the next controller. Slowly, one method at a time. You do not have to convert the whole codebase in a sprint.
After three or four use cases, the team starts to feel the pattern. The pull requests get smaller. The bugs stop touching the framework layer.
The point, restated
The outer ring is the part you can throw away. That is not a weakness of the design. That is the design. Controllers, ORMs, queues, mailers, console commands, HTTP clients: all replaceable. They do not survive a migration. They should not need to.
What survives is the part in the middle. The rules. The use cases. The names your product manager uses in meetings. If you keep those clean, the next framework migration is a weekend instead of a quarter. And if there is no migration, if you stay on Laravel forever, you still get faster tests, cleaner reviews, and a codebase a junior engineer can actually read.
Pick one controller method tomorrow. Move its body to an invokable. That is the first rep.
If this was useful
Decoupled PHP walks the full layout — domain, use cases, ports, adapters — across a real PHP 8.3 codebase, with the Laravel-to-Symfony swap done as a worked exercise. If Go is more your speed, Hexagonal Architecture in Go covers the same shape in a different language. Same target, different rings.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)