Your Test Suite Is an Architecture Report
A slow Laravel test suite gets treated as a tooling problem. It is usually a coupling measurement, and the number worth watching is not how long the suite takes.
The test suite takes eleven minutes. Somebody suggests running it in parallel. Somebody else suggests swapping to an in-memory SQLite database. A third person says the real problem is CI, and if you paid for bigger runners it would be four minutes instead of eleven, which is true, and it costs about as much per month as a decent chair.
All three of those work. That is what makes this the most interesting symptom in the series, because a problem you can buy your way out of usually stops being examined, and this one is worth examining. A slow suite is not primarily a performance fact about your tests. It is a measurement of how much of your application has to exist before you are allowed to ask it a question.
So I want to look at what that number is actually made of, how to get a more useful number out of the same test run, and when the honest answer really is to buy the bigger runner and move on.
What the number is made of
Here is a test I have written many times, and so have you.
it('refuses a refund after the window has closed', function (): void { $user = User::factory()->create();
$order = Order::factory() ->for($user) ->create([ 'total' => 4900, 'purchased_at' => now()->subDays(45), ]);
actingAs($user) ->postJson("/api/orders/{$order->id}/refunds") ->assertStatus(422) ->assertJsonPath('errors.refund.0', 'This order is outside the refund window.');});That test is fine. It passes, it fails when it should, and I would not reject it in review. But look at what has to happen before the assertion runs.
The framework boots. The container resolves. Migrations run, or a schema dump gets loaded. Two factories build models and write rows. An authentication guard resolves a user. A request goes through the HTTP kernel and every piece of global middleware you have. A route resolves, a controller runs, a form request validates, something eventually decides the answer, a response is serialised to JSON, and then, finally, we look at one string.
The thing under test is a comparison between two dates. Everything else is scaffolding for reaching it.
That is the number. Eleven minutes is not eleven minutes of testing, it is eleven minutes of arriving.
The measurement worth taking
Total suite time is a bad metric because it moves for reasons that have nothing to do with your code. Somebody adds tests, it goes up. Somebody buys runners, it goes down. Neither tells you anything about the application.
The number I want is the proportion of your suite that has to touch the database to make an assertion about a rule. In a Laravel project that is unusually easy to get, because the trait is right there in the file.
# tests that need a databasegrep -rl "RefreshDatabase\|DatabaseTransactions\|DatabaseMigrations" tests/ | wc -l
# test files in totalfind tests -name "*Test.php" | wc -lRun those two on your own codebase before you read the next paragraph. The ratio is the interesting part, and it is usually higher than you would have guessed.
If almost every test in your suite needs a database, that is not a statement about your testing discipline. It is a statement about where your decisions live. A rule that can only be reached through the database is a rule that lives past the database, and there is exactly one route to it.
The same rule, twice
Take the decision out of the request path and put it somewhere that has never heard of a request.
final readonly class RefundWindow{ public function __construct( private int $days = 30, ) {}
public function isOpenFor( DateTimeImmutable $purchasedAt, DateTimeImmutable $now, ): bool { return $purchasedAt->add(new DateInterval("P{$this->days}D")) > $now; }}Now the test.
it('refuses a refund after the window has closed', function (): void { $window = new RefundWindow(days: 30);
expect($window->isOpenFor( purchasedAt: new DateTimeImmutable('2026-01-01'), now: new DateTimeImmutable('2026-02-15'), ))->toBeFalse();});No factory. No migration. No container. No RefreshDatabase, which means no transaction to open and roll back. The test runs in the time it takes PHP to construct two objects.
The version of this argument that gets repeated is wrong. The fast test is not better than the slow one. The slow one asserts something the fast one cannot: that the rule is actually wired into the endpoint. Delete the call site and the fast test still passes.
You want both. What changes is the ratio. One test proves the wiring, and it is allowed to be slow because there is one of it. Every test that proves a rule is fast, because the rule is reachable without the machinery.
That is the shift, and it is a shift in where things live rather than in how many tests you write.
Passing time in rather than reading it
There is a second thing that test suite was telling you, and it is easy to miss because Laravel makes it so comfortable.
now() is a global read. Any code that calls it has a hidden input, and a hidden input is untestable without either freezing the clock globally or writing a row with a date on it. Laravel gives you Carbon::setTestNow() and travelTo() to deal with exactly that, and they work, but they work by making the problem global too.
The version above takes $now as an argument. That is not a testing trick, it is the same observation the rest of this series keeps making in different clothes: a dependency you reach out and grab is a dependency you cannot substitute, and time is a dependency.
If your suite is full of travelTo(), that is worth counting as well.
grep -rc "travelTo\|setTestNow\|freezeTime" tests/ | grep -v ':0$' | wc -lWhen the answer is to buy the runner
Now the part I promised, because a diagnostic that only ever concludes “restructure your application” is not a diagnostic, it is an advert.
Slowness on its own is not a reason to move anything. Eleven minutes is annoying, and annoying is cheap to fix with money. If your suite is slow and you can still answer these questions, you do not have an architecture problem, you have a CI invoice.
- When a test fails, do you know which rule broke, or do you have to read the test to find out what it was asserting?
- Can you add a rule without adding a table?
- When a rule changes, does exactly one test fail?
That third one is the real tell. In the first version of the refund test, changing the refund window from thirty days to sixty breaks every test that happened to use an order older than thirty days, including tests about shipping, notifications, and permissions that only created an order because they needed one to exist. The failure count has no relationship to the size of the change.
When that happens, the cost is not in the eleven minutes. It is in the twenty minutes after the eleven, working out which of the forty red tests are red for the reason you expect.
A slow suite where one change breaks one test is a suite you can leave alone. Buy the runner. Genuinely.
What it costs
Pulling a rule out of the request path costs you a class, and I would rather price that honestly than pretend it is free.
You get one more file, one more name to agree on, and one more indirection for a reader to follow from the controller to the thing that decides. On a rule with one caller and one reason to change, that indirection is a cost you will never earn back, and the RefundWindow class above is not obviously worth it for a single date comparison sitting in one endpoint.
It becomes worth it at the second caller. An admin panel that refunds on behalf of a customer, a console command for bulk refunds after an incident, a queued job handling a provider callback. The moment the rule needs to run from a second place, the version that lives in the controller gets copied, and now the rule has two homes and one of them is going to drift.
That is the trigger, and the usual advice gets it wrong. A second reason to run the code, not a line count, and not a slow suite.
What to do on Monday
Run the two grep commands. Write the ratio down somewhere, because it is the only number in this article that is about your application rather than your CI provider.
Then take the single slowest test in your suite and ask what it is asserting. Pest will tell you which one it is.
./vendor/bin/pest --profileIf the slowest test is asserting a rule, that is your first candidate, and it will be a small change. If the slowest test is asserting that a great many pieces fit together, leave it exactly where it is. That one is earning its eleven minutes.
Next in this series: every feature touches ten files, which is sometimes layering working correctly and sometimes a smear, and the difference is not the number of files.
Why Is This Hard To Change?
You are reading Part 1 of 7 in this learning series.
Keep Reading
Building Research
A desktop research workspace in Laravel and NativePHP. Streaming SSE into a queued job, distilling reports with a local model, and why cosine similarity cannot tell a paraphrase from a contradiction.
Aug 2026 · 22 min read
LaravelEvery Feature Touches Ten Files
Ten files open for a one line change is either layering working correctly or one idea smeared across a codebase. The count does not tell you which, and git history does.
Aug 2026 · 4 min read
LaravelIt Was Fine Until We Added A Second One
Every trigger in this series has been a second something. That is not a coincidence, and it is the only signal in here reliable enough to act on.
Aug 2026 · 8 min read