This is the technical companion to a piece I wrote about cutting our CI from ~20 minutes to ~5. That post is the story; this one is the receipts. If you are about to do the same migration in a Laravel or similar framework codebase, this is what the code actually looks like.
I will use generalized examples that mirror our codebase's patterns. The shapes are real. The domain names have been kept generic enough to apply to any application with users, repositories, notifications, and external integrations.
The starting point: a service that does too much
Here is roughly what one of our controller actions looked like before. It is not unusually bad. It is plausibly typical Laravel code.
public function store(StoreOrderRequest $request)
{
$order = Order::create($request->validated());
Mail::to($order->user->email)->send(new OrderCreated($order));
Statsig::logEvent($request->user(), 'order_created', [
'order_id' => $order->id,
]);
if (config('zoho.sync.enabled')) {
SyncOrderJob::dispatch($order->id);
}
return redirect()->route('orders.index');
}
Four direct couplings to the outside world live in that method: the ORM, the mailer, an analytics SDK, and the job queue. Every test of this controller has to either boot all four or fake all four with framework-provided test doubles. The "fake all four" path looks tidy at first and becomes a maintenance burden as soon as the implementation moves.
Step one: name the use cases
The first move is to invent a vocabulary for what this code is asking for. Not "what does it use" but "what does it want done." The naming convention we settled on is For<Verb>ing<Subject>: a port is named for the use case from the application's point of view. This is the suggested naming convention from Cockburn.
namespace App\Application\Order\Ports;
interface ForStoringOrders
{
public function create(array $attributes): Order;
public function update(int $id, array $attributes): Order;
}
interface ForFindingOrders
{
public function find(int $id): ?Order;
public function findByUserId(int $userId): Collection;
}
interface ForSendingOrderNotifications
{
public function notifyOrderCreated(User $user, Order $order): void;
}
interface ForLoggingAnalyticsEvents
{
public function logEvent(User $user, string $name, array $properties = []): void;
}
interface ForSyncingOrdersWithCrm
{
public function queueSync(int $orderId): void;
}
A few things are worth noticing in this shape.
The repository is split into two interfaces, one for reading and one for writing. A consumer that only reads orders declares a dependency only on ForFindingOrders. A consumer that only writes declares only ForStoringOrders. The same adapter class will implement both: the split is about the surface the consumer sees, not the implementation.
ForSendingOrderNotifications does not say "send an email" or "post to Slack." Either could be the production implementation. Both could be. The port is named for the application's intent: notify people that an order was created. The transport is a concern for the adapter.
ForSyncingOrdersWithCrm does not name Zoho, even though Zoho is the production CRM. Six months from now when the CRM gets replaced, the port name still describes what the application wants. The adapter changes; the port does not.
Step two: rewrite the consumer to depend on ports
The controller now talks only to its ports, plus an Action that does the orchestration.
final class CreateOrderAction
{
public function __construct(
private ForStoringOrders $orders,
private ForSendingOrderNotifications $notifications,
private ForLoggingAnalyticsEvents $analytics,
private ForSyncingOrdersWithCrm $crm,
) {}
public function execute(CreateOrderInput $input): Order
{
$order = $this→orders->create($input->toAttributes());
$this->notifications->notifyOrderCreated($input->user, $order);
$this->analytics->logEvent($input->user, 'order_created', [
'order_id' => $order->id,
]);
$this->crm->queueSync($order->id);
return $order;
}
}
public function store(StoreOrderRequest $request, CreateOrderAction $action)
{
$order = $action->execute(CreateOrderInput::fromRequest($request));
return redirect()->route('orders.index');
}
The Action has four dependencies, every one of them an interface owned by the application. The controller now does almost nothing; it translates the HTTP request into an input DTO, calls the Action, and renders a response. That is the entire job of a controller in this architecture.
A note on the if (config('zoho.sync.enabled')) branch that lived in the controller before: it is gone. The "is the CRM enabled" decision is a concern of the CRM port's adapter, not of the Action. The production adapter for ForSyncingOrdersWithCrm reads its own configuration and decides whether to actually dispatch the job. The Action calls queueSync unconditionally, because from the application's point of view it always wants the sync queued; whether that ultimately does anything is the adapter's problem.
Step three: write the production adapters
The production adapters are tech-prefixed and live outside the application namespace. They are allowed to know about Eloquent, the mailer, the SDK, anything they need.
namespace App\Infrastructure\Persistence\Eloquent\Order;
final class EloquentOrders implements ForFindingOrders, ForStoringOrders
{
public function find(int $id): ?Order
{
return Order::find($id);
}
public function findByUserId(int $userId): Collection
{
return Order::where('user_id', $userId)->get();
}
public function create(array $attributes): Order
{
return Order::create($attributes);
}
public function update(int $id, array $attributes): Order
{
$order = Order::findOrFail($id);
$order->update($attributes);
return $order;
}
}
namespace App\Infrastructure\Notifications;
final class MailerOrderNotifications implements ForSendingOrderNotifications
{
public function __construct(private Mailer $mailer) {}
public function notifyOrderCreated(User $user, Order $order): void
{
$this->mailer->to($user->email)->send(new OrderCreated($order));
}
}
namespace App\Infrastructure\Crm\Zoho;
final class ZohoOrderSync implements ForSyncingOrdersWithCrm
{
public function __construct(
private Dispatcher $dispatcher,
private bool $enabled,
) {}
public function queueSync(int $orderId): void
{
if (! $this->enabled) {
return;
}
$this->dispatcher->dispatch(new SyncOrderJob($orderId));
}
}
The "is this enabled" check moved into the Zoho adapter, where it belongs. If we replace Zoho with a different CRM, the new adapter makes its own decision about whether it is enabled. The Action does not need to know. This same strategy can be used to move the notifications (and mailer) into the background queue, or anything that could be enabled. This one happens to use config, but you could also use feature flags or environment variables.
Step four: wire ports to adapters
In Laravel, this is a service provider. The pattern is the same in any DI container.
final class HexagonalServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(ForFindingOrders::class, EloquentOrders::class);
$this->app->bind(ForStoringOrders::class, EloquentOrders::class);
$this->app->bind(ForSendingOrderNotifications::class, MailerOrderNotifications::class);
$this->app->bind(ForLoggingAnalyticsEvents::class, function ($app) {
return new StatsigAnalytics(
enabled: config('services.statsig.enabled'),
client: $app->make(StatsigClient::class),
);
});
$this->app->bind(ForSyncingOrdersWithCrm::class, function ($app) {
return new ZohoOrderSync(
dispatcher: $app->make(Dispatcher::class),
enabled: config('zoho.sync.enabled'),
);
});
}
}
Note that EloquentOrders is bound twice: once for the find port, once for the store port. The container resolves the same class for both. Other consumers can ask for only the half they need, and the container does the right thing.
Step five: write the in-memory adapters
This is where the tests get fast. The in-memory adapter is a real implementation of the port, backed by an array, with no framework boot, no migrations, no transactions.
namespace Tests\Support\InMemory\Order;
final class InMemoryOrders implements ForFindingOrders, ForStoringOrders
{
/** @var array<int, Order> */
private array $orders = [];
private int $nextId = 1;
public function find(int $id): ?Order
{
return $this->orders[$id] ?? null;
}
public function findByUserId(int $userId): Collection
{
return collect($this->orders)
->filter(fn (Order $o) => $o->user_id === $userId)
->values();
}
public function create(array $attributes): Order
{
$order = new Order(array_merge($attributes, ['id' => $this->nextId]));
$order->exists = true;
$this->orders[$this->nextId] = $order;
$this->nextId++;
return $order;
}
public function update(int $id, array $attributes): Order
{
if (! isset($this->orders[$id])) {
throw new ModelNotFoundException();
}
$this->orders[$id]->fill($attributes);
return $this->orders[$id];
}
}
A few things worth pointing out.
This is not a mock. It does not record calls. It is a real implementation that satisfies the same contract as the Eloquent adapter. A test can call create and then call findByUserId and get the right answer, because the adapter actually keeps the data.
ID generation is local to the adapter. The Eloquent adapter delegates ID generation to the database. The in-memory adapter does it from a counter. The port does not care. The contract is "returns a Order with an id set" and both adapters satisfy it.
There is no test-framework coupling. This class extends nothing. It can be used in any test, including ones that do not boot Laravel at all.
Step six: write the recording adapters
Some ports are command-shaped: they do something, they do not answer something. For those, the in-memory adapter is a "recording fake": it executes the command into local state that the test can inspect.
namespace Tests\Support\InMemory\Notifications;
final class RecordingOrderNotifications implements ForSendingOrderNotifications
{
/** @var array<int, array{user: User, order: Order}> */
public array $sent = [];
public function notifyOrderCreated(User $user, Order $order): void
{
$this->sent[] = ['user' => $user, 'order' => $order];
}
}
This is the pattern that replaces Mail::fake() and Queue::fake() and Event::fake() across the codebase. The framework fakes are mocks under a friendlier name: they intercept calls and let you assert on them. Recording adapters are fakes that satisfy the same need with a state-based contract. The difference shows up when you refactor: a recording adapter does not care whether the notification was dispatched from the Action directly or from a domain event handler downstream, as long as the state is right at the end. A mock cares, and breaks.
The same pattern works for analytics and CRM:
final class RecordingAnalytics implements ForLoggingAnalyticsEvents
{
/** @var array<int, array{user: User, name: string, properties: array}> */
public array $events = [];
public function logEvent(User $user, string $name, array $properties = []): void
{
$this->events[] = compact('user', 'name', 'properties');
}
}
final class RecordingOrderSync implements ForSyncingOrdersWithCrm
{
/** @var int[] */
public array $queued = [];
public function queueSync(int $orderId): void
{
$this->queued[] = $orderId;
}
}
Step seven: write the test
The Action now has four dependencies, all of which are ports, all of which have in-memory implementations. The test wires them by hand, calls the Action, and inspects state.
final class CreateOrderActionTest extends TestCase
{
public function test_creates_an_order_and_fans_out_side_effects(): void
{
$orders = new InMemoryOrders();
$notifications = new RecordingOrderNotifications();
$analytics = new RecordingAnalytics();
$crm = new RecordingOrderSync();
$action = new CreateOrderAction($orders, $notifications, $analytics, $crm);
$user = UserBuilder::new()->build();
$input = new CreateOrderInput($user, ['amount' => 100]);
$order = $action->execute($input);
$this->assertSame($order, $orders->find($order->id));
$this->assertCount(1, $notifications->sent);
$this->assertSame($order->id, $notifications->sent[0]['order']->id);
$this->assertCount(1, $analytics->events);
$this->assertSame('order_created', $analytics->events[0]['name']);
$this->assertEquals([$order->id], $crm->queued);
}
}
That test class extends plain PHPUnit\Framework\TestCase. It does not boot Laravel. It does not load a service container. It does not migrate a database. On our suite, that single change (moving a test class from the framework-aware base to a plain PHPUnit base) saved between 150ms and 400ms per test class. Multiply by hundreds of test classes and the CI numbers start to make sense.
The assertions are state-based: after the Action ran, the orders store contains the order, the notifications recorder has one entry, the analytics recorder has one event with the right name, the CRM recorder has the right id queued. None of those assertions know how the Action was implemented. The Action could spin up an internal command bus, fire events, do whatever it wants. Doesn't matter: the test verifies the contract, not the call graph.
What about the integration tests
The integration tests are smaller, fewer, and intentional. There is one test per production adapter, and its job is to verify that the production adapter satisfies the same contract the in-memory adapter does.
final class EloquentOrdersTest extends DatabaseTestCase
{
public function test_create_persists_and_find_retrieves(): void
{
$adapter = new EloquentOrders();
$created = $adapter->create(['user_id' => 1, 'amount' => 500]);
$retrieved = $adapter->find($created->id);
$this->assertNotNull($retrieved);
$this->assertSame(500, $retrieved->amount);
}
}
These tests do hit the database. They have to; that is the whole point. They are also a small fraction of the suite, because there is one of them per port, not one per Action that happens to use the port. Two dozen integration tests cover what used to be hundreds of Action tests, because each integration test answers a different question: "does the production adapter honor its contract?" rather than "does this Action do the right thing?"
When the contract changes, both the in-memory adapter and the production adapter get updated, and both sets of tests catch a mismatch. That has happened on our codebase a handful of times. Once for a soft-delete behavior we had baked into the in-memory adapter but not the Eloquent one, and once the other way around. The contract pair caught it both times.
A few things that bit us
Lazy loading in the in-memory adapter. Our Eloquent adapters return models with relationships unloaded by default; callers use ->load() or with() to materialize them. The first version of our in-memory adapter returned models with relationships eagerly populated, because that was easier. Tests passed against the in-memory adapter and exploded against Eloquent because callers were not loading relationships they assumed were there. The fix was to make the in-memory adapter match the Eloquent laziness behavior. Adapters being honest about the production contract matters more than adapters being convenient.
ID generation in tests. A test that creates two orders expects two different ids. A naive in-memory adapter that uses count() + 1 for ids breaks the moment a test deletes an order. Use a monotonic counter, not a derived value.
Latent Eloquent calls inside Actions. Most Actions are clean: they take ports as constructor dependencies and only talk to them. A few had snuck in direct Model::query() calls during prior refactors. The test would pass with the in-memory adapter wired in for the explicit ports, then fail in a hard-to-debug way because some other line in the Action was still hitting the database directly. The fix is to keep Actions honest: every external call goes through a constructor-injected port. There is no other way out.
Laravel's ::fake() helpers. I mentioned this in the strategy post; it bears repeating with code. Mail::fake() works by replacing the mail manager with a spy that records calls. It is structurally a mock. It couples your tests to the implementation detail of when and how mail gets dispatched. The recording adapter approach replaces it with a state-based fake that survives refactoring. The cost is writing one recording class per port. The benefit is a test suite that does not need to be rewritten every time you move a side effect from one place to another.
What the directory layout looks like
The whole pattern lives in three trees.
app/
Application/
Orders/
Actions/
CreateOrderAction.php
Inputs/
CreateOrderInput.php
Ports/
ForFindingOrders.php
ForStoringOrders.php
ForSendingOrderNotifications.php
Infrastructure/
Persistence/
Eloquent/
Order/
EloquentOrders.php
Notifications/
MailerOrderNotifications.php
Crm/
Zoho/
ZohoOrderSync.php
Providers/
HexagonalServiceProvider.php
tests/
Unit/
Application/
Order/
Actions/
CreateOrderActionTest.php
Integration/
Infrastructure/
Persistence/
Eloquent/
EloquentOrdersTest.php
Support/
InMemory/
Order/
InMemoryOrders.php
Notifications/
RecordingOrderNotifications.php
The trick is that the application namespace knows nothing about the infrastructure namespace. Application code imports ForFindingOrders, never EloquentOrders. The compiler (or in PHP's case, the autoloader and your own discipline) enforces that boundary. Static analysis can enforce it too: a rule that forbids App\Infrastructure\* imports inside App\Application\* will catch any backsliding.
The CI numbers, in detail
Here is what changed in the test runner over the migration, on a sample subset where I have clean before-and-after data:
| Test class | Before (DB-backed) | After (port-backed) | Delta |
|---|---|---|---|
| CreateOrderActionTest | ~1.8s | ~50ms | -97% |
| OrderNotificationServiceTest | ~1.2s | ~30ms | -97% |
| OrderCalculatorTest | ~2.1s | ~80ms | -96% |
| UserDashboardActionTest | ~2.4s | ~90ms | -96% |
| UserOnboardingTest | ~3.0s | ~120ms | -96% |
Per-class deltas are roughly two orders of magnitude. The suite is hundreds of classes. The aggregate is the headline number.
Two costs survive that the table does not show. The first is the integration test suite, which still hits the database and still runs slowly per test, but contains far fewer tests. The second is fixture setup that we kept on the database side deliberately: a small set of end-to-end tests that exercise an entire request through the framework, because that is the only way to verify the framework wiring itself.
The total CI time we now ship with is in the 5-minute range, broken down as roughly: 2.5 min for the bulk of unit tests, 1.5 min for integration tests against production adapters, 1 min for end-to-end framework-aware tests. The exact split moves around week to week, but the shape holds.
What to take away
The thing that made this work was not the architecture diagram. It was the discipline of naming dependencies for use cases, splitting reads from writes, refusing to use mocks, and being willing to extend a plain test case instead of the framework's.
The architecture is the scaffolding. The fakes are the speed. Both have to be in place. Skip either one and you will have built something elegant that does not deliver the CI win, or built fast tests on top of a coupled architecture that will be slow again as soon as the next feature lands.
If you are reading this in the middle of your own migration and CI just went up: that is the climb. Keep porting. The drop is real.
Top comments (0)