Development

Speed Up Your Laravel Test Suite: The Cache Testing Traits You've Been Waiting For.

7 minutes

Right, let's have a chat about test suite performance. You know that feeling when you push your code, then go make a brew while waiting for the CI pipeline to finish? Yeah, we've been there too. Laravel 12.38 just dropped two new testing traits that might just save you enough time to actually drink that tea while it's still hot.

Meet WithCachedRoutes and WithCachedConfig - two deceptively simple traits that can cut your test suite run time dramatically.

The Hidden Cost of Feature Tests

We all know that writing feature tests is important and the more test coverage you have in your application the safer you will sleep at night. But here's something that might surprise you: every time one of your feature tests runs, Laravel rebuilds your entire application from scratch. Every. Single. Test.

We're talking about:

  • Loading every service provider

  • Reading and parsing every route file

  • Processing every configuration file

  • Registering all your middleware

  • Setting up all your bindings

Now multiply that by however many tests you have. Got 500 tests? That's 500 times your application is being completely rebuilt. No wonder your test suite takes forever.

Why We Never Noticed Before

When Laravel applications are small, this overhead is negligible. Your typical starter app with a dozen routes and the default config files? The bootstrap time is maybe minimal. Not worth thinking about.

But then your application grows. You add an API. Then an admin panel. Maybe you split things into modules for different features. You integrate with third-party services, each with their own config files. Before you know it, you've got:

  • 15 route files (web, api, admin, webhooks, internal...)

  • 30+ config files

  • Multiple service providers registering components

  • Route model bindings everywhere

  • Global middleware stacks

Suddenly that 50ms bootstrap time is 200ms. Still doesn't sound like much? Let's do the maths: 500 tests × 200ms = 100 seconds. That's over a minute and a half of your test suite just booting Laravel, not actually testing anything.

Enter the Caching Traits

This is where Laravel 12.38's new traits come in (huge props to Luke Kuzmish who contributed these to core). Instead of rebuilding everything for each test, these traits build your routes and config once, then reuse them for every test in your suite.

Using WithCachedRoutes

The simplest approach? Add it to your base test case:

php
1<?php
2 
3namespace Tests;
4 
5use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
6use Illuminate\Foundation\Testing\WithCachedRoutes;
7 
8abstract class TestCase extends BaseTestCase
9{
10 use WithCachedRoutes;
11}

That's it. Every test that extends your TestCase now benefits from cached route registration.

Adding WithCachedConfig

Same story with configuration:

php
1<?php
2 
3namespace Tests;
4 
5use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
6use Illuminate\Foundation\Testing\WithCachedRoutes;
7use Illuminate\Foundation\Testing\WithCachedConfig;
8 
9abstract class TestCase extends BaseTestCase
10{
11 use WithCachedRoutes;
12 use WithCachedConfig;
13}

For the Pest Enthusiasts

Using Pest? Even easier - just add them globally in your Pest.php file:

php
1<?php
2 
3use Illuminate\Foundation\Testing\WithCachedRoutes;
4use Illuminate\Foundation\Testing\WithCachedConfig;
5 
6pest()->use(WithCachedRoutes::class, WithCachedConfig::class);

Now every test benefits without touching a single test file.

Example: API Test Suite

Let me show you a scenario you might encounter frequently. You're building an e-commerce platform with separate route files for different concerns:

php
1<?php
2 
3// routes/api/products.php
4Route::prefix('products')->group(function () {
5 Route::get('/', [ProductController::class, 'index']);
6 Route::get('/search', [ProductController::class, 'search']);
7 Route::get('/{product}', [ProductController::class, 'show']);
8 Route::post('/', [ProductController::class, 'store']);
9 Route::put('/{product}', [ProductController::class, 'update']);
10 Route::delete('/{product}', [ProductController::class, 'destroy']);
11});
12 
13// routes/api/orders.php
14Route::prefix('orders')->group(function () {
15 Route::get('/', [OrderController::class, 'index']);
16 Route::post('/', [OrderController::class, 'store']);
17 Route::get('/{order}', [OrderController::class, 'show']);
18 Route::post('/{order}/fulfill', [OrderController::class, 'fulfill']);
19 Route::post('/{order}/cancel', [OrderController::class, 'cancel']);
20});
21 
22// routes/api/customers.php
23// ... another dozen routes
24 
25// routes/admin.php
26// ... 50+ admin routes

Your test might look like this:

php
1<?php
2 
3namespace Tests\Feature\Api;
4 
5use App\Models\Product;
6use Illuminate\Foundation\Testing\RefreshDatabase;
7use Tests\TestCase;
8 
9class ProductApiTest extends TestCase
10{
11 use RefreshDatabase;
12 
13 /** @test */
14 public function can_list_products()
15 {
16 Product::factory()->count(3)->create();
17 
18 $this->getJson('/api/products')
19 ->assertOk()
20 ->assertJsonCount(3, 'data');
21 }
22 
23 /** @test */
24 public function can_search_products()
25 {
26 Product::factory()->create(['name' => 'Laravel T-Shirt']);
27 Product::factory()->create(['name' => 'PHP Mug']);
28 
29 $this->getJson('/api/products/search?q=laravel')
30 ->assertOk()
31 ->assertJsonCount(1, 'data');
32 }
33 
34 /** @test */
35 public function can_create_product()
36 {
37 $this->postJson('/api/products', [
38 'name' => 'New Product',
39 'price' => 29.99
40 ])
41 ->assertCreated();
42 
43 $this->assertDatabaseHas('products', [
44 'name' => 'New Product'
45 ]);
46 }
47}

Without the caching traits, each of these three tests loads and parses all your route files. With the traits? Once for the entire test class.

The Performance Impact

We've heard from developers out there that they are seeing impressive results across different application types:

  • SaaS application with 200 routes: Test suite down from 3:20 to 2:15

  • API with 80+ endpoints: Tests run in half the time

  • Small application with 500 tests: Still saved 15-20 seconds

I myself have just implemented this on a small internal project that I've recently upgraded to Laravel 12 that I'm trying to resurrect, it doesn't have a huge code base and a huge test suite it currently has 543 tests, implementing these two traits in the Pest test suite globally cut the test time down by 2seconds taking it from 15s to 13 seconds which might not sound like a lot but over time that really does add up.

The improvements are even more dramatic with parallel testing, as each process benefits from the caching.

What About Laravel's Built-in Caching?

"But wait," you might say, "can't I just use php artisan route:cache?"

Well, yes and no. The problem with using Laravel's built-in caching commands for tests is:

  1. You'd need to remember to run php artisan route:cache --env=testing before tests

  2. The cached files can interfere with your local development

  3. Different developers might have different cached states

  4. Your CI pipeline needs extra setup steps

These traits solve all that by keeping everything in memory, just for the duration of your test run. No files to manage, no cache to clear, no confusion.

When Should You Use These Traits?

The short answer? Pretty much always, unless you have a specific reason not to. These traits are particularly beneficial for:

  • Modular monoliths where each module registers its own routes and config

  • Large applications with many route files or extensive configuration

  • Package development when you're loading multiple service providers

  • CI/CD pipelines where every second counts

  • Local development when you're running tests frequently

The Gotchas (Because There's Always Something)

While these traits are fantastic, there are a couple of edge cases to be aware of:

Dynamic Route Registration

If you have tests that modify route registration based on test conditions, the cached routes won't reflect those changes:

php
1<?php
2// This won't work as expected with WithCachedRoutes
3public function setUp(): void
4{
5 parent::setUp();
6 
7 if ($this->shouldRegisterAdminRoutes()) {
8 Route::prefix('admin')->group(function () {
9 // Dynamic routes based on test conditions
10 });
11 }
12}

In these rare cases, simply don't use the trait for that specific test class.

Configuration Mutations

Similarly, if you're modifying configuration values that affect how the application boots:

php
1<?php
2// Be careful with this pattern when using WithCachedConfig
3public function setUp(): void
4{
5 parent::setUp();
6 
7 // This change won't affect the cached config
8 config(['app.providers' => array_merge(
9 config('app.providers'),
10 [TestServiceProvider::class]
11 )]);
12}

For tests that need to modify boot-level configuration, skip the caching trait.

A Practical Testing Strategy

Here's how we're approaching it:

  1. Add traits to base TestCase - Get the benefits everywhere by default

  2. Run your test suite - Check everything still passes

  3. Benchmark the improvement - Measure the time saved

  4. Handle exceptions - Remove traits from specific test classes if needed

Most applications will see improvements with zero changes to actual test code.

The Technical Bit

For those curious about the implementation, these traits use static properties to store the built routes and configuration. When the first test runs, it builds everything normally but stores the result. Subsequent tests skip the building phase and use the stored version.

It's clever because it:

  • Requires no configuration

  • Works with parallel testing

  • Doesn't create any files

  • Automatically cleans up after your tests finish

Beyond Integration Tests

As Luke wisely notes in his article, while these traits provide huge wins for integration tests, remember that unit tests will always be faster:

"Using a test which boots and tears down the application will always be slower than a plain old PHPUnit test. Anything that can be tested via a unit test should be, but for the high confidence that integration tests can offer, these traits are a huge win."

So yes, use these traits, but also remember to:

  • Write unit tests where possible

  • Use Factory::make() when database writes aren't necessary

  • Be mindful of model events that trigger queries

  • Test single units of code when appropriate

The Bottom Line

Look, nobody enjoys waiting for tests to run. These traits won't make your tests instant, but they should make them noticeably faster. And faster tests mean you'll run them more often, catch bugs earlier, and spend less time staring at loading bars.

The best part? It takes literally 30 seconds to implement. Add the traits, run your tests, enjoy the speed boost. It's the kind of quick win we all need.

Try It Today

Laravel 12.38 is available now, so there's nothing stopping you from trying these traits today:

shell
1composer update laravel/framework

Add the traits to your base TestCase class, run your benchmarks, and prepare to be impressed. We'd love to hear what kind of improvements you're seeing.

And remember, faster tests mean you'll run them more often, which means you'll catch bugs earlier, which means happier clients and fewer late-night debugging sessions. Everyone wins.

Looking to start your next Laravel project?

We’re a passionate, happy team and we care about creating top-quality web applications that stand out from the crowd. Let our skilled development team work with you to bring your ideas to life! Get in touch today and let’s get started!

Get in touch