- 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 know how this conversation starts. Someone on the team reads a blog post about hexagonal architecture, draws a hexagon on the whiteboard, and proposes a rewrite. Two sprints of "design." A feature freeze that the product manager kills on day three. A new repo that nobody on the team has time to populate while the old one keeps shipping bugs.
Three months in, the rewrite is half-finished. The Eloquent models in the old codebase have grown four more relationships. The new "domain layer" sits in a branch that nobody dares rebase. The team quietly returns to the original repo, slightly more demoralized than when they started.
There is a different path: four to six weeks, one PR per week, each PR compiling, passing the existing suite, shipping to production before the next one starts. No feature freeze, no new repo, no greenfield. At the end of it, your Laravel service has a domain that doesn't know Eloquent exists, controllers that fit on one screen, and tests that run without a database.
A team I worked with ran a sequence like this on a Laravel order service. Weekly PRs, normal sprint velocity, no customer-visible changes. This post is the playbook they used, adapted for PHP 8.3.
Where you start
The codebase that grew. Controllers call Eloquent directly. Business logic lives inside update() and store(). Form Requests handle validation; everything else lands in the controller. The directory tree looks something like this.
app/
├── Http/Controllers/
│ ├── OrderController.php # 600 lines
│ └── CustomerController.php
├── Models/
│ ├── Order.php # Eloquent, also has biz methods
│ ├── Coupon.php
│ └── Customer.php
├── Http/Requests/
│ └── StoreOrderRequest.php
├── Mail/
│ └── OrderCreated.php
└── Providers/
Here is the part of OrderController::store() that does the work. Validation comes from the Form Request, then a coupon lookup runs against Coupon::where(...), the totals get computed inline, the order is persisted with Order::create(...), and a mail is queued. All in one method.
public function store(StoreOrderRequest $request): JsonResponse
{
$data = $request->validated();
$discount = 0.0;
if (!empty($data['coupon_code'])) {
$coupon = Coupon::where('code', $data['coupon_code'])
->where('active', true)
->first();
if ($coupon !== null) {
$discount = (float) $coupon->discount;
}
}
$total = 0;
foreach ($data['items'] as $item) {
$total += $item['price_cents'] * $item['quantity'];
}
$total = (int) round($total * (1 - $discount));
$order = Order::create([
'customer_id' => $data['customer_id'],
'total_cents' => $total,
'status' => 'pending',
]);
Mail::to($data['email'])->queue(new OrderCreated($order));
return response()->json($order, 201);
}
Five concerns in twenty lines: HTTP shape, persistence, query, business math, side effect. Every test for this method needs SQLite-in-memory at minimum, and the mail fake. Adding a second entry point, say an admin command that creates orders, means copy-pasting the math.
You could rewrite this. Or you could move five things, one per week.
Week 1 — Carve out a Domain namespace
The smallest possible PR. You create app/Domain/ and move plain data structures into it. No Eloquent. No traits. No facades. Just PHP types.
app/
├── Domain/
│ └── Order/
│ ├── Order.php # readonly DTO, no Eloquent
│ ├── Item.php
│ └── OrderStatus.php # enum
├── Http/Controllers/ # untouched
├── Models/ # still Eloquent
└── ...
Order becomes a readonly class. No magic, no relations, no $fillable.
namespace App\Domain\Order;
use DateTimeImmutable;
final class Order
{
/** @param Item[] $items */
public function __construct(
public readonly string $id,
public readonly string $customerId,
public readonly array $items,
public readonly int $totalCents,
public readonly OrderStatus $status,
public readonly DateTimeImmutable $createdAt,
) {}
}
final class Item
{
public function __construct(
public readonly string $sku,
public readonly int $quantity,
public readonly int $priceCents,
) {}
}
enum OrderStatus: string
{
case Pending = 'pending';
case Confirmed = 'confirmed';
case Cancelled = 'cancelled';
}
The Eloquent App\Models\Order still exists. Controllers still use it. The only addition is one mapping function at the boundary, used wherever you want to return a domain Order instead of a model, and you don't even have to call it yet. This PR just adds files.
What this PR does not do: delete the Eloquent model, change a controller, move a single line of business logic. It introduces a namespace and a handful of value objects. Every existing test passes without modification. Reviewers can skim it in five minutes.
The check that protects this layer for the rest of the migration. Paste this into your CI script. Composer's autoload-dev is fine for the helper.
# Domain must not import framework code.
! grep -rE "Illuminate\\\\|Eloquent|use App\\\\Models" app/Domain \
|| (echo "FAIL: Domain leaks framework imports" && exit 1)
Run it locally before pushing. The day it goes red is the day someone reached for Auth::user() from inside a domain method, and you want to know about it inside the PR, not in production.
Week 2 — Define ports next to the domain
A port is an interface that describes what the domain needs from the outside world. It does not describe how to get it. In Laravel terms: the port is what would normally be a repository or a service contract, but it lives in App/Domain/, not in App/Repositories/, and it knows nothing about Eloquent.
namespace App\Domain\Order;
interface OrderRepository
{
public function save(Order $order): void;
public function findById(string $id): ?Order;
/** @return Order[] */
public function findByCustomer(string $customerId): array;
}
namespace App\Domain\Order;
interface CouponRepository
{
public function findActiveByCode(string $code): ?Coupon;
}
namespace App\Domain\Order;
interface OrderNotifier
{
public function notifyCreated(Order $order, string $email): void;
}
And alongside the ports, a domain service that orchestrates them. This is where the business math you stripped out of the controller will live. Constructor injection only — no facades, no app() helper, no traits.
namespace App\Domain\Order;
use DateTimeImmutable;
use Ramsey\Uuid\Uuid;
final class CreateOrder
{
public function __construct(
private readonly OrderRepository $orders,
private readonly CouponRepository $coupons,
private readonly OrderNotifier $notifier,
) {}
/** @param Item[] $items */
public function handle(
string $customerId,
array $items,
?string $couponCode,
string $email,
): Order {
if ($items === []) {
throw new InvalidOrderException(
'order requires at least one item',
);
}
$discount = 0.0;
if ($couponCode !== null) {
$coupon = $this->coupons->findActiveByCode($couponCode);
$discount = $coupon?->discount ?? 0.0;
}
$order = new Order(
id: Uuid::uuid7()->toString(),
customerId: $customerId,
items: $items,
totalCents: $this->total($items, $discount),
status: OrderStatus::Pending,
createdAt: new DateTimeImmutable(),
);
$this->orders->save($order);
$this->notifier->notifyCreated($order, $email);
return $order;
}
/** @param Item[] $items */
private function total(array $items, float $discount): int
{
$sum = 0;
foreach ($items as $item) {
$sum += $item->priceCents * $item->quantity;
}
return (int) round($sum * (1 - $discount));
}
}
The controller still works the old way. The new CreateOrder is written but not yet wired into any HTTP endpoint. You can already test it without a database, though, and this is the moment the speed of your test suite changes.
final class CreateOrderTest extends TestCase
{
public function test_applies_coupon_discount(): void
{
$orders = new InMemoryOrderRepository();
$coupons = new InMemoryCouponRepository([
'SAVE10' => new Coupon('SAVE10', 0.10),
]);
$notifier = new NullNotifier();
$useCase = new CreateOrder($orders, $coupons, $notifier);
$order = $useCase->handle(
customerId: 'cust-1',
items: [new Item('A', 2, 1500)],
couponCode: 'SAVE10',
email: 'a@b.com',
);
$this->assertSame(2700, $order->totalCents);
$this->assertCount(1, $orders->saved());
}
}
No SQLite. No RefreshDatabase. No mock framework. The fakes are 15 lines each and they live in the test file. The test runs in two milliseconds. When tests cost two milliseconds you write more of them, and your discount-rounding bug gets caught the first time you change the math. Customers never get to screenshot it.
Week 3 — Wrap Eloquent as an adapter
This is the PR that feels big and is actually mechanical. You take the SQL hiding inside Coupon::where(...) and Order::create(...) and you move it into a class that implements OrderRepository and CouponRepository. You are moving Eloquent calls, not rewriting them.
app/
├── Domain/
│ └── Order/
│ ├── Order.php
│ ├── Item.php
│ ├── OrderStatus.php
│ ├── OrderRepository.php
│ ├── CouponRepository.php
│ ├── OrderNotifier.php
│ └── CreateOrder.php
├── Adapter/
│ ├── Persistence/
│ │ ├── EloquentOrderRepository.php
│ │ └── EloquentCouponRepository.php
│ └── Mail/
│ └── LaravelMailNotifier.php
├── Models/ # still here, still Eloquent
└── Http/Controllers/ # still here, untouched this week
The Eloquent repository is the boring part. It maps between Eloquent models and domain types at the boundary.
namespace App\Adapter\Persistence;
use App\Domain\Order\Item;
use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;
use App\Domain\Order\OrderStatus;
use App\Models\Order as EloquentOrder;
use DateTimeImmutable;
final class EloquentOrderRepository implements OrderRepository
{
public function save(Order $order): void
{
EloquentOrder::updateOrCreate(
['id' => $order->id],
[
'customer_id' => $order->customerId,
'total_cents' => $order->totalCents,
'status' => $order->status->value,
'created_at' => $order->createdAt,
],
);
}
public function findById(string $id): ?Order
{
$row = EloquentOrder::with('items')->find($id);
return $row === null ? null : $this->toDomain($row);
}
public function findByCustomer(string $customerId): array
{
return EloquentOrder::with('items')
->where('customer_id', $customerId)
->orderByDesc('created_at')
->get()
->map(fn ($row) => $this->toDomain($row))
->all();
}
private function toDomain(EloquentOrder $row): Order
{
$items = $row->items->map(
fn ($i) => new Item($i->sku, $i->quantity, $i->price_cents),
)->all();
return new Order(
id: $row->id,
customerId: $row->customer_id,
items: $items,
totalCents: $row->total_cents,
status: OrderStatus::from($row->status),
createdAt: new DateTimeImmutable($row->created_at),
);
}
}
The mail notifier is even smaller. It implements the port, calls Mail::queue(), returns void.
namespace App\Adapter\Mail;
use App\Domain\Order\Order;
use App\Domain\Order\OrderNotifier;
use App\Mail\OrderCreated;
use Illuminate\Support\Facades\Mail;
final class LaravelMailNotifier implements OrderNotifier
{
public function notifyCreated(Order $order, string $email): void
{
Mail::to($email)->queue(new OrderCreated($order));
}
}
Bind the interfaces to the implementations in a service provider. This is where the framework re-enters the picture, and it is the only place that needs to know which concrete class implements which port.
namespace App\Providers;
use App\Adapter\Mail\LaravelMailNotifier;
use App\Adapter\Persistence\EloquentCouponRepository;
use App\Adapter\Persistence\EloquentOrderRepository;
use App\Domain\Order\CouponRepository;
use App\Domain\Order\OrderNotifier;
use App\Domain\Order\OrderRepository;
use Illuminate\Support\ServiceProvider;
final class DomainServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
$this->app->bind(CouponRepository::class, EloquentCouponRepository::class);
$this->app->bind(OrderNotifier::class, LaravelMailNotifier::class);
}
}
Nothing in the HTTP layer changed this week. The controller is still fat. But the domain is now production-wired through the container. Every dependency that the new CreateOrder use case needs is resolvable. Next week, the controller starts to shrink.
Week 4 — Make the controller an adapter
Now you flip the wiring. The controller stops doing work. It parses input, delegates to CreateOrder, formats output. That is the entire job description for a controller in a hexagonal Laravel app.
namespace App\Http\Controllers;
use App\Domain\Order\CreateOrder;
use App\Domain\Order\InvalidOrderException;
use App\Domain\Order\Item;
use App\Http\Requests\StoreOrderRequest;
use Illuminate\Http\JsonResponse;
final class OrderController
{
public function __construct(
private readonly CreateOrder $createOrder,
) {}
public function store(StoreOrderRequest $request): JsonResponse
{
$data = $request->validated();
$items = array_map(
fn (array $i) => new Item($i['sku'], $i['quantity'], $i['price_cents']),
$data['items'],
);
try {
$order = $this->createOrder->handle(
customerId: $data['customer_id'],
items: $items,
couponCode: $data['coupon_code'] ?? null,
email: $data['email'],
);
} catch (InvalidOrderException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
return response()->json([
'id' => $order->id,
'total_cents' => $order->totalCents,
'status' => $order->status->value,
], 201);
}
}
The before-and-after on this one controller is the whole point of the migration. The old store() had 20 lines of mixed concerns. The new one has three jobs in three blocks: build the input DTOs, call the use case, shape the response. The Eloquent call, the coupon lookup, and the mail send are all gone. The math is gone. The controller no longer needs a DB::transaction() wrapper because the use case decides what is transactional and what isn't.
What the controller still owns: the JSON shape and the HTTP status codes. That is a property of the HTTP transport, not of the business rule, so it belongs in an adapter. If next month a teammate adds a Filament admin page that also creates orders, the new entry point calls CreateOrder->handle() directly with no duplication.
Run the full feature test suite after this PR. If your route, your validation rules, and your response shape are unchanged, every existing test passes. The behavior at the HTTP boundary is identical. Only the inside moved.
Week 5 — Drain the Eloquent model of behavior
Up to this point the App\Models\Order Eloquent class might still have a total() method, a applyCoupon() method, a scope or two with business semantics. This week you delete those methods and prove no one outside the adapter calls them.
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Order extends Model
{
protected $fillable = [
'id', 'customer_id', 'total_cents', 'status', 'created_at',
];
protected $casts = [
'created_at' => 'datetime',
];
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}
That is the new shape of the Eloquent model: fillable, casts, relations. No business methods, no scopes that hide a domain rule. The model is a database row with a Laravel face on it, and that is fine. Eloquent is good at being a database row; it is bad at being a business invariant.
grep for the methods you deleted. If anything outside app/Adapter/Persistence/ calls them, that code is reaching past the port directly into the persistence model. Move it to a use case in the domain, or to a method on the repository if it is genuinely a persistence concern.
git grep -nE '->(applyCoupon|computeTotal|markConfirmed)\b' \
-- ':!app/Adapter/Persistence/'
# desired result: no matches
The same scan applies to facades inside app/Domain/. The domain must not call DB::, Cache::, Mail::, Auth::, or Log:: directly. Any of those callers indicate a missing port. Add the port to app/Domain/Order/, add the adapter to app/Adapter/, update the binding.
Week 6 — Lock the boundary
The migration is done. The job that remains is making sure it stays done. Two CI checks and one architectural test.
The first check is the one from week 1, expanded. The domain must not import the framework, the Eloquent models, or the adapters.
#!/usr/bin/env bash
set -e
if grep -rE "Illuminate\\\\|App\\\\Models|App\\\\Adapter" app/Domain ; then
echo "FAIL: app/Domain imports outward"
exit 1
fi
if grep -rE "Mail::|DB::|Auth::|Cache::|Log::|Storage::" app/Domain ; then
echo "FAIL: app/Domain uses a Laravel facade"
exit 1
fi
The second check uses deptrac or phparkitect, whichever your team prefers. A deptrac.yaml for this layout:
deptrac:
paths: [app]
layers:
- name: Domain
collectors:
- { type: directory, regex: app/Domain/.* }
- name: Adapter
collectors:
- { type: directory, regex: app/Adapter/.* }
- name: Http
collectors:
- { type: directory, regex: app/Http/.* }
ruleset:
Domain: []
Adapter: [Domain]
Http: [Domain]
Domain depends on nothing. Adapters depend on the domain. The HTTP layer depends on the domain, not on an adapter directly; the binding flows through the container. Deptrac will refuse a PR that violates that direction.
The third check is the test pyramid. Most of your tests should be domain unit tests that run in milliseconds. A handful should be adapter integration tests that hit a real database. A thin top layer should be Laravel feature tests that exercise the full stack. If your test ratio is the inverse — mostly feature tests, a handful of unit tests — your domain is still leaking, and the migration is incomplete regardless of what the directory tree looks like.
What you do and don't get
You do not get a different application. The routes are the same. The database schema is the same. The mail goes out the same way to the same recipients. Customers cannot tell the migration happened.
What you do get is a grip on the parts that were painful before. Adding a CLI command that creates orders takes 30 lines because it calls CreateOrder directly. Swapping the mail driver, the coupon source, or the persistence layer is a one-class change. Onboarding a new engineer onto the use case takes 20 minutes instead of three days, because the use case fits on one screen and reads like the product spec.
And (the part the rewrite-from-scratch plan never delivers) you got it in six weekly PRs while shipping the rest of the roadmap. The team can look at the calendar and see the day the change landed. They never had to negotiate a feature freeze. Nobody had to keep two versions of anything in their head.
| Week | PR scope | What changes | What stays |
|---|---|---|---|
| 1 |
app/Domain/ namespace |
New readonly value types + enums | Controllers, models, tests |
| 2 | Ports + CreateOrder use case |
Interfaces and one orchestrator | Controllers still call DB |
| 3 | Eloquent + Mail adapters | Adapters implement ports | Controller logic untouched |
| 4 | Controller becomes adapter | Controller thins to 3 jobs | HTTP contract identical |
| 5 | Drain Eloquent of behavior | Models = persistence only | Domain already done |
| 6 | CI gates the boundary | deptrac + grep checks in CI | Forever, hopefully |
Six PRs. Each one reviewable in under an hour. Each one deployable on its own merits. The service is hexagonal at the end of it, and the only people who notice are the engineers who used to dread opening OrderController.
If this was useful
The full step-by-step is the spine of Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework. The book covers the parts this post skipped: transactions across multiple adapters, error translation at the HTTP boundary, queue workers as inbound adapters, the day a framework upgrade tries to break your domain and fails. If you write Go too, Hexagonal Architecture in Go walks the same shape in a different language.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)