Speeding Up Laravel Test Suites: Practical Techniques That Work

Categories: Laravel
7 min read

A slow test suite is a drag on the whole development cycle. It slows CI, breaks flow, and - more insidiously - makes developers stop running tests locally because they can't afford the wait. On a multi-tenant Laravel SaaS I work on as freelance lead developer, we've pushed a large suite down to a fraction of its original runtime using a handful of practical techniques. This post covers what actually works: parallel execution, smarter database handling, faking the right things, and a few CI tweaks that compound everything else.

Why Test Suite Speed Actually Matters

The feedback loop is the thing. When a suite runs in two minutes, developers run it constantly - before committing, after a refactor, when something looks odd. When it runs in twenty minutes, they run it once before pushing and hope. That's a much worse feedback loop, and it shows in the defect rate.

CI time matters too. A slow suite means longer PR queues, delayed deploys, and developers waiting around for a green light. The cost compounds across every PR merged. Getting a suite from fifteen minutes to four isn't a vanity exercise - it changes how the team works.

For more on the testing approach itself, see my notes on Laravel best practices for startups - this post is specifically about making an existing suite fast, not what to test.

1. ParaTest: Parallel Execution

This is the highest-leverage change you can make. ParaTest runs your PHPUnit tests in parallel across multiple processes. On a four-core machine, that's roughly a 3-4x throughput improvement on a typical suite. On CI runners with more cores, it's even better.

Install it via Composer:

composer require --dev brianium/paratest

Then run tests with:

vendor/bin/paratest --processes=4

The main thing to handle is database isolation. Each parallel process needs its own database so tests don't stomp on each other. Laravel's built-in parallel testing support (introduced in Laravel 9) handles this automatically if you use php artisan test --parallel, which uses ParaTest under the hood. If you're calling ParaTest directly and need finer control, set up per-process database tokens using ParaTest::TEST_TOKEN in your test bootstrap.

One gotcha: tests that depend on global state (static properties, singletons that persist between tests, anything that doesn't reset) can fail non-deterministically in parallel. These would have been hidden problems anyway - parallel testing just surfaces them. Fix the isolation, don't disable the parallelism.

2. In-Memory SQLite for Unit Tests, Real DB for Integration

Not all tests need a real database. Pure unit tests - things that test business logic, data transformations, or calculations - can run against an in-memory SQLite database and finish in milliseconds.

Set up a separate PHPUnit config or environment for your unit tests:

DB_CONNECTION=sqlite
DB_DATABASE=:memory:

SQLite in-memory databases are created fresh for each test run and require no disk I/O. For a suite of 200 unit tests, this can shave a minute off your runtime with zero code changes beyond the config.

The trade-off is real. SQLite doesn't support every MySQL or PostgreSQL feature: certain JSON queries, full-text search, and some constraint types behave differently or not at all. Keep your integration and feature tests - the ones that hit real routes or exercise the full stack - pointed at your real database driver. Use SQLite only where the test doesn't depend on database-specific behaviour.

3. fast-refresh-database: Skip Unnecessary Migrations

This is the one that surprised me most when I first saw the numbers. On a large app, running migrations before every test class can dominate test suite time - especially as the migration count grows. The plannr/laravel-fast-refresh-database package solves this cleanly.

Install it:

composer require --dev plannr/laravel-fast-refresh-database

Then swap RefreshDatabase for FastRefreshDatabase in your test cases. The package checksums your migration files. If nothing has changed since the last run, it skips the full migration cycle and just truncates tables - a much faster operation. When you do add a migration, the checksum changes and the full migration runs once, then subsequent runs go back to the fast path.

On the SaaS I work on, we had over 200 migrations. Running them per test class was adding several minutes per CI run. Fast-refresh-database cut that to near-zero for unchanged runs. It's a straightforward drop-in and one of the best return-on-investment changes we made.

4. Fake External Services

Tests that make real HTTP requests to third-party APIs are slow, fragile, and not actually testing your code - they're testing whether the API is up. Fake them.

Laravel's Http::fake() intercepts outgoing HTTP requests and returns whatever you configure:

Http::fake([
    'api.stripe.com/*' => Http::response(['id' => 'evt_123'], 200),
]);

For email, use Mail::fake(). For queued jobs, Queue::fake(). For notifications, Notification::fake(). Laravel's fakes cover almost everything you'd reach for, and they're fast: no network round-trips, no rate limits, no timeouts.

The other benefit is determinism. A test that calls a live API can fail because the API is slow, or returns something unexpected, or your credentials expire. A faked test fails only when your code is wrong. That's the signal you want.

For more complex external dependencies, consider wrapping them behind an interface and swapping in a fast in-memory implementation for tests. That's more work, but it pays off on systems where the same external service is exercised dozens of times across the suite.

5. Run Only What You Need with --filter

PHPUnit's --filter flag runs only tests whose name matches a pattern. During development, this is invaluable:

php artisan test --filter=PaymentControllerTest
php artisan test --filter=test_invoice_is_generated

You don't need the full suite to get confidence on a specific change. Run the focused subset, iterate fast, then run the full suite before pushing. Combined with ParaTest on CI, this means you get fast local feedback during development and thorough coverage before merge - without making either one slow.

PHPUnit also supports test groups (@group annotations or #[Group] attributes in PHP 8). Grouping your slowest integration tests separately means you can exclude them with --exclude-group=slow for quick local runs, then include everything on CI.

6. Profile Before You Optimise

Before reaching for any of the above, find out where the time is actually going. PHPUnit can log test timings:

php artisan test --log-junit storage/logs/test-results.xml

Or use ParaTest's built-in timing output. Sort by duration, look at the top ten slowest tests. Frequently you'll find one or two tests accounting for a disproportionate chunk of the runtime - a test that seeds a huge dataset, one that doesn't fake an external call, one that runs migrations unnecessarily. Fix the outliers first.

I've seen suites where a single test class with a missing Http::fake() was making fifteen real API calls and adding forty seconds on its own. Profile first, optimise second.

7. CI Tips That Compound Everything Else

Parallelism at the test level only goes so far if CI itself is a bottleneck. A few things that help:

  • Cache Composer dependencies. Most CI providers support dependency caching keyed on composer.lock. A cache hit turns a 60-second install into a few seconds. Do the same for Node if you're building assets before tests.
  • Use a fast CI runner. GitHub Actions' larger runners have more cores, which means more ParaTest processes. The cost difference is small relative to developer time saved across every PR.
  • Split your suite if it's huge. GitHub Actions supports matrix jobs. Split tests into groups and run them in parallel CI jobs. Each job runs ParaTest internally, so you get two levels of parallelism: across jobs and within each job.
  • Don't run the full suite on every push to feature branches. Run a focused subset on feature branches and the full suite on PRs to main. That reduces noise without reducing confidence where it matters.

Putting It Together

The combination that's worked best on the multi-tenant SaaS I work on: ParaTest with multiple processes on CI, fast-refresh-database to skip redundant migrations, Http::fake() and friends for external services, SQLite in-memory for pure unit tests, and Composer dependency caching on GitHub Actions. Each change individually made a measurable difference. Together they moved a suite that was becoming painful into one that developers actually run without thinking about it.

None of this is complicated to set up. The hardest part is usually the isolation work that parallel testing surfaces - fixing tests that were sharing state and getting away with it. That work pays for itself: you end up with a cleaner, more reliable suite as well as a faster one.

I'm a freelance Laravel developer based in Wiltshire, near Bath, working with startups and scale-ups across Bristol, Bath, and the UK. If your test suite has become a drag on the team or your CI pipeline is slowing down deploys, reach out - I've done this on real projects and can help you get there without disrupting the team. For broader architecture context, see my notes on scaling Laravel backends.

Ben Lumley StackOverflow Github Linkedin

Related posts