For most of the past year, our CI was a 20-minute ceremony. Push a branch, walk away, come back to either green or a red flake. That is a sign your feedback loop is no longer a feedback loop; it is a coffee break.
Then it got worse. While we were migrating the codebase to hexagonal architecture, CI climbed to roughly 24 minutes. That climb is the part of the story I want to talk about, because it is the part most blog posts skip. The win on the other side is real. The dip in the middle is also real, and if you do not expect it, you will give up before you get to the win.
Today, the same suite runs in about 5 minutes. Same test count. Same coverage. The difference is that almost none of those tests touch the database anymore.
Why the database was the bottleneck
Our Laravel test suite had grown up around RefreshDatabase. Every test got a clean schema, migrated from scratch in a transaction, populated with factories, exercised, then rolled back. That pattern is convenient and very easy to reach for. It is also linear in the amount of schema you have, and ours was not small.
We did not have a slow-tests problem. We had a slow-architecture problem expressed as a test suite. The tests were faithfully exercising the same coupling that made the application hard to reason about: a controller calls a model, which calls another model, which fires an observer, which dispatches a job, which writes to a third table. To test any of it honestly, you had to stand up all of it.
Mocking was not the answer either, because the mocks would lie about behavior the database actually enforced.
What hexagonal architecture actually changed
The shorthand version: every external dependency the application uses (a repository, a notifier, a CRM client, a feature-flag service) got pulled behind a named interface owned by the application. The framework, the ORM, and the third-party SDKs moved to the outside as adapters. Inside the application, code talks only to the interfaces.
The mental model that helped me most was naming the interfaces for the use case, not for the technology. We do not have an OrderRepository interface. We have ForFindingOrders and ForStoringOrders. The application is not asking for "a repository"; it is asking for "the thing I use to find orders." Once the interfaces are named that way, two adapters fall out naturally: an Eloquent one for production, an in-memory one for tests.
The in-memory adapter is the unlock. It is not a mock. It is a real implementation with real behavior: it holds an array, it returns objects, it enforces uniqueness if the production adapter does. It is just backed by RAM instead of a database. A test that depends on ForFindingOrders does not care which one it gets. The application code does not change. The wiring changes.
Once that pattern was in place across the codebase, tests stopped needing the database. They stopped needing migrations. They stopped needing transactions. They stopped needing each other.
The climb before the drop
For the first eight or nine weeks of the migration, CI got slower. There are two reasons that happened, and both are worth naming.
First, we were running the new architecture and the old one side by side. Every new port-and-adapter pair existed alongside the legacy service it was replacing. Tests covered both. That is unavoidable during a real migration (you do not get to delete the old path until the new path is proven), but it costs you in the middle.
Second, the first wave of port-backed tests did not yet skip the database. They still extended the framework test case. They still ran migrations. The InMemory adapters existed but were used in maybe a third of the relevant tests. The benefit shows up only when a whole test class can be promoted off the database test case onto a unit test case that knows nothing about Laravel's kernel.
The temptation during that climb is enormous. CI is slower. The architecture is more code. Your team is asking when the payoff arrives. The right answer is to keep going, because the payoff is non-linear. The first hundred ported tests buy you nothing. The next hundred buy you everything, because at some point a critical mass of test classes can be cut loose from the database entirely, and the wall-clock time falls off a cliff.
That cliff, for us, happened over about three weeks. CI went from ~24 minutes to ~16, then to ~10, then to ~5. We did not change the test runner. We did not parallelize harder. We just stopped paying the database tax on tests that did not need the database.
What the numbers actually look like
Rough breakdown, before and after, on the same suite:
| Stage | Total wall-clock | DB-backed test classes | Pure unit test classes |
|---|---|---|---|
| Pre-migration | ~20 min | ~95% | ~5% |
| Mid-migration peak | ~24 min | ~95% | ~5% |
| Today | ~5 min | ~25% | ~75% |
The headline is the total, but the more telling number is the second column. We did not get faster by writing fewer tests. We got faster by writing the same tests against fakes instead of against a live database.
The remaining ~25% that still hits the database is not a failure to migrate: it is deliberate. Integration tests against the production Eloquent adapters need to run somewhere, and that somewhere is CI. The bar for keeping a test on the database is: this test exists specifically to verify that the production adapter does what its port says it does. Everything else lives in unit-test land.
The principles that made it work
A handful of decisions did most of the heavy lifting. They are not unique to Laravel and they are not unique to this codebase. They are unique to taking the architecture seriously.
Name ports for the use case, not the technology. ForFindingOrders reads like a sentence the application is asking for. OrderRepositoryInterface reads like a category from a Java textbook. The first one tells you, at the call site, what the caller wants. The second one tells you what aisle to look in. The application is the caller; the application's vocabulary wins.
Split read and write ports. A consumer that only reads does not need to declare a dependency on a write capability it never uses. Splitting ForFindingOrders from ForStoringOrders makes the dependencies in each Action obvious from its constructor. A single concrete adapter can implement both interfaces. The split is about the consumer's surface, not the implementation's.
No mocks. Real fakes. A mock is a script: when you call this method with these arguments, return that. A fake is an implementation: a real, working version of the dependency, just with a different backing store. Mocks couple your tests to the order and shape of internal calls. Fakes let you write state-based assertions: "after this Action, the orders repository contains exactly one order with status X." That assertion survives refactoring. Mock-based assertions do not.
This rule had a sharp edge for us: Laravel's ::fake() helpers are mocks under a friendly name. They record calls and let you assert on them. They are convenient and they are also exactly the kind of test double the principle warns against. Replacing each one with a port and a recording adapter (a fake that records, queryable as state) was tedious but paid for itself the first time a refactor moved a dispatch from one place to another and the mock-style tests would have lit up red for no real reason.
Move the test base case. Half the speedup is not in the assertions; it is in the test class hierarchy. A test that extends Laravel's TestCase boots the framework. A test that extends a plain PHPUnit TestCase does not. When a test class can be moved off the framework-aware base, do it. The runtime difference per class is small. The aggregate is enormous.
Keep one foot in the integration world. Do not delete every database-backed test. The contract between a port and its production adapter has to be verified somewhere, and that somewhere is integration tests that hit the real adapter. The discipline is to keep that set small and intentional, not to let it grow back by accident.
What I would tell someone starting this migration
Expect the climb. Plan for it. Tell your team the curve looks like a J, because it does. The first half of a hex migration in a real codebase is more code, more tests, more CI minutes, and a vague feeling that you have made the system more complicated to prove a point. The second half is the payoff, and it is large enough that nobody on the team will remember the climb a quarter later.
Do not try to migrate everything at once. Pick one bounded context. Get its ports defined, its adapters built, its consumers cut over, its tests off the database. Demonstrate the time saved. Repeat. Each context you migrate makes the next one easier, because the patterns are now in the codebase and the tooling (base test cases, in-memory adapter conventions, service-provider wiring) is reusable.
And do not negotiate on the "no mocks" rule. It is the rule that pays. The architecture buys you the seams; fakes buy you the speed. Skip the fakes and you will have built the architecture and kept the slow tests.
Five minutes is not the goal. The goal is a feedback loop short enough that developers do not avoid running tests locally. We crossed that line somewhere around eight minutes. Everything after that has been a bonus.
Top comments (0)