DEV Community

Cover image for Migrating a Legacy Laravel Service to Hexagonal, Incrementally
Gabriel Anhaia
Gabriel Anhaia

Posted on

Migrating a Legacy Laravel Service to Hexagonal, Incrementally


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/
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Five concerns tangled in a Laravel controller, separated by week into domain, ports, adapters

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
└── ...
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

A thin Laravel controller delegating to a domain use case through ports, with Eloquent and Mail as adapters

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)