DEV Community

Cover image for Laravel as an Adapter: A 90-Minute Refactor That Survives Framework Upgrades
Gabriel Anhaia
Gabriel Anhaia

Posted on

Laravel as an Adapter: A 90-Minute Refactor That Survives Framework Upgrades


You inherited the file: OrderController.php, four hundred lines, a store method that does eighteen things. An Eloquent model with two mutators, a global scope, and a booted() hook that fires emails, writes to a ledger table, and busts cache keys as a side effect of saving a row. The original author left in 2021. The person who understood the model events left in 2023. You are the one who has to add a currency field to the order payload by Friday without breaking the 03:00 UTC reconciliation job.

You do not have a week. You have an afternoon. You do not need one. Ninety minutes. Five steps, one endpoint at a time. Each step keeps the app bootable and the test suite green. At the end of the afternoon, Laravel is still in the project (it is the framework you boot in artisan serve), but it is not the application anymore. It is one of the edges. When the next major Laravel release lands, the use case does not care.

This is the playbook.

A controller pulled inside-out: HTTP and Eloquent moved to the edges, a use case at the center.

What the starting point looks like

Look at it honestly before touching anything. The fat method:

namespace App\Http\Controllers;

use App\Models\Order;
use App\Models\Customer;
use App\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderConfirmation;

class OrderController extends Controller
{
    public function store(Request $request)
    {
        $data = $request->validate([
            'customer_id' => 'required|integer|exists:customers,id',
            'items'       => 'required|array|min:1',
            'items.*.sku' => 'required|string',
            'items.*.qty' => 'required|integer|min:1',
            'coupon'      => 'nullable|string',
        ]);

        $customer = Customer::findOrFail($data['customer_id']);
        if ($customer->status !== 'active') {
            return response()->json(
                ['error' => 'customer inactive'],
                422,
            );
        }

        return DB::transaction(function () use ($data, $customer) {
            $order = new Order();
            $order->customer_id = $customer->id;
            $order->status = 'pending';
            $order->currency = $customer->preferred_currency ?? 'EUR';

            $subtotal = 0;
            foreach ($data['items'] as $line) {
                $product = Product::where('sku', $line['sku'])
                    ->lockForUpdate()
                    ->firstOrFail();
                if ($product->stock < $line['qty']) {
                    abort(409, "out of stock: {$line['sku']}");
                }
                $product->stock -= $line['qty'];
                $product->save();
                $subtotal += $product->price_cents * $line['qty'];
            }

            if (($data['coupon'] ?? null) === 'WELCOME10') {
                $subtotal = (int) round($subtotal * 0.9);
            }

            $order->total_cents = $subtotal;
            $order->save();

            foreach ($data['items'] as $line) {
                $order->lines()->create([
                    'sku' => $line['sku'],
                    'qty' => $line['qty'],
                ]);
            }

            Mail::to($customer->email)
                ->send(new OrderConfirmation($order));

            return response()->json([
                'id'     => $order->id,
                'total'  => $order->total_cents / 100,
                'status' => $order->status,
            ], 201);
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

Count what is happening: HTTP validation, customer load, stock walk with row locks, coupon math, persistence, email, response shaping. Seven jobs in one method. And the model is not innocent either:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;

class Order extends Model
{
    protected $fillable = [
        'customer_id', 'status', 'currency', 'total_cents',
    ];

    protected static function booted(): void
    {
        static::created(function (Order $order) {
            Cache::tags(['orders'])->forget(
                "customer:{$order->customer_id}:orders"
            );
            \DB::table('ledger')->insert([
                'account' => "customer:{$order->customer_id}",
                'amount'  => $order->total_cents,
                'ref'     => "order:{$order->id}",
            ]);
        });

        static::addGlobalScope('tenant', function ($builder) {
            $builder->where('tenant_id', tenant_id());
        });
    }

    public function setStatusAttribute(string $value): void
    {
        $allowed = ['pending', 'paid', 'shipped', 'cancelled'];
        if (!in_array($value, $allowed, true)) {
            throw new \InvalidArgumentException(
                "invalid status: $value"
            );
        }
        $this->attributes['status'] = $value;
    }
}
Enter fullscreen mode Exit fullscreen mode

The model fires cache invalidation, writes to a ledger table, scopes every query by tenant, and enforces a status enum through a setter. Business rules live in three places: controller, model, helper function. Every test path needs MySQL. The bug you are about to introduce on Friday is structural. It has nothing to do with you.

Step 1 (15 minutes): Define the domain entity alongside the model

Create App\Domain\Order\Order. Pure PHP. No extends Model. It lives in a different namespace from App\Models\Order. The legacy code keeps importing the old class. Nothing else moves yet.

namespace App\Domain\Order;

use App\Domain\Customer\CustomerId;
use App\Domain\Shared\Money;
use DateTimeImmutable;

final class Order
{
    /** @var DomainEvent[] */
    private array $pendingEvents = [];

    private function __construct(
        public readonly OrderId $id,
        public readonly CustomerId $customerId,
        /** @var LineItem[] */
        private array $lineItems,
        public readonly DateTimeImmutable $placedAt,
        private OrderStatus $status,
    ) {}

    public static function place(
        OrderId $id,
        CustomerId $customerId,
        array $lineItems,
        DateTimeImmutable $now,
    ): self {
        if ($lineItems === []) {
            throw new \DomainException(
                'Order must have at least one line item.'
            );
        }
        $currency = $lineItems[0]->unitPrice->currency;
        foreach ($lineItems as $li) {
            if ($li->unitPrice->currency !== $currency) {
                throw new \DomainException(
                    'All line items must share a currency.'
                );
            }
        }
        $order = new self(
            $id,
            $customerId,
            $lineItems,
            $now,
            OrderStatus::Placed,
        );
        $order->pendingEvents[] = new OrderPlaced(
            $id, $customerId, $now,
        );
        return $order;
    }

    public function total(): Money
    {
        $total = Money::zero(
            $this->lineItems[0]->unitPrice->currency,
        );
        foreach ($this->lineItems as $li) {
            $total = $total->add($li->subtotal());
        }
        return $total;
    }

    public function status(): OrderStatus
    {
        return $this->status;
    }

    /** @return LineItem[] */
    public function lineItems(): array
    {
        return $this->lineItems;
    }

    public function releaseEvents(): array
    {
        $events = $this->pendingEvents;
        $this->pendingEvents = [];
        return $events;
    }
}
Enter fullscreen mode Exit fullscreen mode

The status string with a hand-rolled setter is gone. OrderStatus is a real PHP 8 enum. The cart-must-not-be-empty rule moves out of the controller and into the constructor of the verb that creates orders. The currency-mismatch rule, which the legacy code never checked at all, gets enforced at the type level.

Around the entity, add the small things: OrderId, CustomerId, Money, OrderStatus, LineItem. Immutable. Plain PHP. Nothing imports Eloquent.

This is not a parallel rewrite. The legacy model still serves every request. The new entity sits unused except by unit tests. You can write those tests now, without booting Laravel:

public function test_place_rejects_empty_cart(): void
{
    $this->expectException(\DomainException::class);

    Order::place(
        OrderId::generate(),
        new CustomerId('cust-1'),
        [],
        new DateTimeImmutable(),
    );
}
Enter fullscreen mode Exit fullscreen mode

Runs in milliseconds. No MySQL container. No RefreshDatabase trait.

Step 2 (10 minutes): Define the port

The use case will talk to persistence through an interface that lives in the domain, named in the domain's language, returning domain types.

namespace App\Domain\Order;

use App\Domain\Customer\CustomerId;

interface OrderRepository
{
    public function find(OrderId $id): ?Order;

    public function save(Order $order): void;

    /** @return Order[] */
    public function findActiveByCustomer(
        CustomerId $customerId,
    ): array;
}
Enter fullscreen mode Exit fullscreen mode

Three methods. No firstOrFail, no paginate, no withTrashed. The port answers questions the business asks, in words the business uses. find returns ?Order (nullable rather than throw-on-missing), and the use case decides what null means.

The interface sits in the domain namespace because dependency flows inward. The Eloquent implementation does not exist yet. That is fine. The use case can already be written against this contract.

Step 3 (20 minutes): Wrap the legacy model as an adapter

This is the step that feels biggest and breaks the least. You take the Eloquent model that already runs in production and wrap it. The adapter is the only file in the codebase that knows both shapes (domain and Eloquent) and maps between them.

namespace App\Infrastructure\Persistence\Eloquent;

use App\Domain\Customer\CustomerId;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderRepository;
use App\Models\Order as EloquentOrder;

final class EloquentOrderRepository
    implements OrderRepository
{
    public function find(OrderId $id): ?Order
    {
        $row = EloquentOrder::where('uuid', $id->value)
            ->with('lines')
            ->first();

        return $row === null
            ? null
            : OrderMapper::toDomain($row);
    }

    public function save(Order $order): void
    {
        $total = $order->total();
        $row = EloquentOrder::firstOrNew(
            ['uuid' => $order->id->value],
        );
        $row->customer_id = $order->customerId->value;
        $row->currency    = $total->currency;
        $row->total_cents = $total->amountInMinorUnits;
        $row->status      = $order->status()->value;
        $row->save();

        $row->lines()->delete();
        foreach ($order->lineItems() as $line) {
            $row->lines()->create([
                'sku' => $line->productId->value,
                'qty' => $line->quantity,
                'unit_price_cents' =>
                    $line->unitPrice->amountInMinorUnits,
            ]);
        }
    }

    public function findActiveByCustomer(
        CustomerId $customerId,
    ): array {
        $rows = EloquentOrder::where(
            'customer_id', $customerId->value,
        )
            ->whereNotIn(
                'status', ['cancelled', 'refunded'],
            )
            ->with('lines')
            ->get();

        return $rows
            ->map(fn($r) => OrderMapper::toDomain($r))
            ->all();
    }
}
Enter fullscreen mode Exit fullscreen mode

The mapping is dull on purpose. The legacy model keeps its mutators, its global tenant scope, its booted() hook. None of that leaks past the adapter. The tenant scope still fires every query because the adapter queries through the Eloquent model. The ledger insert still runs on created because the model event still fires. Those are kept alive on purpose during the migration. The domain stays clean above them, and you address each one only when you are ready.

The adapter is the seam: domain on one side, Eloquent on the other, mapper in between.

Step 4 (30 minutes): Extract the use case

The use case is the verb. PlaceOrder. Input in, ports called, output out. No Request, no JSON, no response().

namespace App\Application\Order;

use App\Application\Port\{Clock, EventBus};
use App\Domain\Catalog\ProductRepository;
use App\Domain\Customer\{CustomerId, CustomerNotFound,
    CustomerRepository};
use App\Domain\Order\{Coupon, LineItem, Order, OrderId,
    OrderRepository, ProductId};
use App\Domain\Shared\Money;

final readonly class PlaceOrder
{
    public function __construct(
        private OrderRepository $orders,
        private CustomerRepository $customers,
        private ProductRepository $products,
        private EventBus $events,
        private Clock $clock,
    ) {}

    public function execute(
        PlaceOrderInput $input,
    ): PlaceOrderOutput {
        $customer = $this->customers->find(
            new CustomerId($input->customerId),
        ) ?? throw new CustomerNotFound(
            $input->customerId,
        );

        $lineItems = [];
        foreach ($input->items as $requested) {
            $product = $this->products->ofSku(
                $requested['productId'],
            ) ?? throw new UnknownProduct(
                $requested['productId'],
            );
            $product->reserve($requested['quantity']);
            $this->products->save($product);

            $lineItems[] = new LineItem(
                new ProductId($requested['productId']),
                $requested['quantity'],
                new Money(
                    $product->priceCents(),
                    $input->currency,
                ),
            );
        }

        $order = Order::place(
            OrderId::generate(),
            $customer->id,
            $lineItems,
            $this->clock->now(),
        );

        $total = $input->coupon !== null
            ? Coupon::fromCode($input->coupon)
                ->applyTo($order->total())
            : $order->total();

        $this->orders->save($order);
        $this->events->publishAll(
            $order->releaseEvents(),
        );

        return new PlaceOrderOutput(
            orderId: $order->id->value,
            totalCents: $total->amountInMinorUnits,
            currency: $input->currency,
            status: $order->status()->value,
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

It reads as a paragraph: resolve the customer, walk the products, build the line items, place the order. Then apply the coupon, save, publish events, return the output. No transaction handling in the body. That is a job for a transactional decorator at the boundary, applied the same way to every use case.

The verb did not exist before. It was a side effect of OrderController::store. Now it is a class with one method, callable from HTTP, a CLI command, or a queue worker. You can test it with in-memory fakes:

public function test_place_order_charges_total_with_coupon(): void
{
    $orders = new InMemoryOrderRepository();
    $useCase = new PlaceOrder(
        $orders,
        new InMemoryCustomerRepository(['cust-1']),
        new InMemoryProductRepository([
            'sku-1' => ['price' => 1000, 'stock' => 10],
        ]),
        new NullEventBus(),
        new FixedClock(new DateTimeImmutable('2026-01-01')),
    );

    $output = $useCase->execute(new PlaceOrderInput(
        customerId: 'cust-1',
        items: [['productId' => 'sku-1', 'quantity' => 2]],
        currency: 'EUR',
        coupon: 'WELCOME10',
    ));

    $this->assertSame(1800, $output->totalCents);
}
Enter fullscreen mode Exit fullscreen mode

Microseconds. No DB. No HTTP kernel. No RefreshDatabase.

Step 5 (15 minutes): Slim the controller

The controller becomes protocol translation. HTTP in, use case call, HTTP out.

namespace App\Http\Controllers;

use App\Application\Order\PlaceOrder;
use App\Application\Order\PlaceOrderInput;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class OrderController extends Controller
{
    public function __construct(
        private PlaceOrder $placeOrder,
    ) {}

    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([
            'customer_id'        => 'required|string',
            'currency'           => 'required|string|size:3',
            'items'              => 'required|array|min:1',
            'items.*.productId'  => 'required|string',
            'items.*.quantity'   => 'required|integer|min:1',
            'coupon'             => 'nullable|string',
        ]);

        $output = $this->placeOrder->execute(
            new PlaceOrderInput(
                customerId: $data['customer_id'],
                items: $data['items'],
                currency: $data['currency'],
                coupon: $data['coupon'] ?? null,
            ),
        );

        return response()->json([
            'id'       => $output->orderId,
            'total'    => $output->totalCents,
            'currency' => $output->currency,
            'status'   => $output->status,
        ], 201);
    }
}
Enter fullscreen mode Exit fullscreen mode

Thirty lines. Validates input, builds a DTO, calls the use case, shapes the response. The container resolves PlaceOrder and its dependencies. One binding in a service provider (or bootstrap/app.php in Laravel 11) tells the container that OrderRepository resolves to EloquentOrderRepository. The rest is autowired.

// app/Providers/AppServiceProvider.php
public function register(): void
{
    $this->app->bind(
        \App\Domain\Order\OrderRepository::class,
        \App\Infrastructure\Persistence\Eloquent\EloquentOrderRepository::class,
    );
}
Enter fullscreen mode Exit fullscreen mode

Tomorrow someone adds an orders:place Artisan command. It builds the same PlaceOrderInput, calls the same PlaceOrder, returns the same PlaceOrderOutput. Day after, the queue worker that picks orders off RabbitMQ does the same. HTTP, CLI, queue worker, all calling the same PlaceOrder.

What you do with the leftovers

The legacy App\Models\Order still exists. The booted() hook still fires. The global tenant scope still scopes every query. You did not delete any of it. The adapter wraps the legacy code rather than replacing it. Migration becomes a question you can answer one item at a time, instead of a freeze week.

The cleanup queue, in priority order:

  1. Domain events for business effects. The ledger insert in booted() is a business effect ("record a credit entry when an order is placed"). It becomes a subscriber to OrderPlaced, dispatched via the EventBus port after save(). The hook in the model can be deleted once the subscriber lands.
  2. Infrastructure subscribers for the rest. The cache bust in booted() is infrastructure. A second OrderPlaced subscriber calls cache.invalidateCustomerOrders($id) through a cache port. The domain stays unaware that a cache exists.
  3. Global scopes become explicit repository methods. addGlobalScope('tenant', ...) becomes OrderRepository::activeForTenant(TenantId, CustomerId). The use case names the question. New joiners stop being surprised when Order::count() returns zero in a tinker session.
  4. Mutators move to value objects or invariants. setStatusAttribute validated a string against a list. That is what OrderStatus is for. The accessor that formatted currency moves to Money::format(), called by whichever adapter needs presentation.

Pick one item per sprint. Each one shrinks the legacy model. The model gets deleted on the day no subscriber, no scope, no mutator on it does anything you cannot find elsewhere. That day arrives because each refactor moved one specific thing.

Why this survives framework upgrades

The bet is on the import graph. App\Domain\* imports nothing from Illuminate\*. App\Application\* imports App\Domain\* and App\Application\Port\*, also nothing from Laravel. Only App\Http\* and App\Infrastructure\* import Laravel. When the next major Laravel release reshuffles the bootstrap layout, or you decide in 2028 that you want to run the same use cases on Symfony, the diff touches a service provider and one adapter file. The verbs do not move.

Two CI checks keep the boundary honest:

# domain must not import the framework
! grep -rE "use (Illuminate|Symfony)" \
    app/Domain app/Application
Enter fullscreen mode Exit fullscreen mode
# adapter must implement a port, never define one
! grep -rE "^interface " app/Infrastructure
Enter fullscreen mode Exit fullscreen mode

Wire them into your pipeline. A regression caught at PR review costs five minutes. One that festers for a year costs the next migration week you do not want to do.

What ninety minutes buys you

One endpoint. Five steps. Laravel still boots, routes, validates. The model still exists. The migration table is unchanged. Nothing in production looks different from the outside.

But the controller is thirty lines. The verb has a name and a class. The next developer who reads the codebase can find where orders are placed by opening one file, and the domain test suite runs in milliseconds. When the next framework upgrade lands, the part of the code that contains the business does not need to know it happened.

Pick the controller in your codebase that has more than fifty lines in a single action. Count three things: how many models it imports, how many things happen inside DB::transaction, and how many lines validate the input. Then rewrite the method as a paragraph in plain English: "When a customer X, the system should A, then B, then C, and return D." If you can write the paragraph without mentioning Eloquent, Mail, Cache, or Request, you have just sketched the use case. That paragraph is the spec for Step 4. Now repeat for the next endpoint tomorrow afternoon.


If this was useful

The five-step playbook above is the spine of Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework. The book walks the same shape from the first chapter to the last: domain at the center, ports at the boundary, Laravel and Symfony as adapters, with full migration playbooks for both frameworks. Every chapter has a runnable example in the public examples repo. The production patterns section covers the parts this post had to skip: transactions across adapters, domain events, observability through ports, the strangler pattern for living with both worlds during the migration year.

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)