Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

Rajtik76's avatar

Laravel parallel testing with separate storage for views per process

Background

In one of my projects, I encountered an interesting problem when running parallel tests, some of them randomly failed, each time on the Blade template. The template was not always the same and it didn't make any sense at all. Moreover, the tests always ended up on a problem somewhere in the middle of the Filament logic in the template.

I found that when I run the tests in a classic single thread, everything works and no test ever fails. I ran them repeatedly and every time without an error. Only in the parallel environment did occasional errors occur.

This led me to the idea that I need to separate the storage for the view by process. Just like for the DB.

And that's what my subsequent code solves. If any of you need a similar solution, you can be inspired by my solution, I'll be happy if it helps someone at least a little.

The solution is built for Laravel 11 and higher.

Solution

First, we need to register custom \App\Providers\ViewServiceProvider in bootstrap\app.php instead of the original \Illuminate\View\ViewServiceProvider. This change in service provider registration is in Laravel since version 11 (I think):

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\ServiceProvider;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__ . '/../routes/web.php',
        commands: __DIR__ . '/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {})
    ->withExceptions(function (Exceptions $exceptions): void {})
    ->withProviders(ServiceProvider::defaultProviders()
        ->merge([

        ])
        ->replace([\Illuminate\View\ViewServiceProvider::class => \App\Providers\ViewServiceProvider::class])
        ->toArray()
    )
    ->create();

Then we need to register our custom BladeCompiler class. I also tried to rebind the BladeCompiler class but I didn't found a way how to rebind singleton so we can replace it:

namespace App\Providers;

use App\Services\BladeCompiler;
use Illuminate\View\DynamicComponent;

class ViewServiceProvider extends \Illuminate\View\ViewServiceProvider
{
    public function registerBladeCompiler()
    {
        $this->app->singleton('blade.compiler', function ($app) {
            return tap(new BladeCompiler(
                $app['files'],
                $app['config']['view.compiled'],
                $app['config']->get('view.relative_hash', false) ? $app->basePath() : '',
                $app['config']->get('view.cache', true),
                $app['config']->get('view.compiled_extension', 'php'),
                $app['config']->get('view.check_cache_timestamps', true),
            ), function ($blade) {
                $blade->component('dynamic-component', DynamicComponent::class);
            });
        });
    }
}

In BladeCompiler there is a new method setCachePath() which creates a new directory for views and sets this directory to $this->cachePath. The path can be changed using VIEW_COMPILED_PATH.
This directory is only created if a process token is passed. This method is called from a modified compile() where the process token is passed as a parameter. All this is wrapped in a condition which ensures that we will only create directories when running in a test environment:

namespace App\Services;

use Illuminate\Support\Facades\ParallelTesting;

class BladeCompiler extends \Illuminate\View\Compilers\BladeCompiler
{
    protected function setCachePath(false|string $token): void
    {
        if (is_string($token)) {
            $this->cachePath = config('view.compiled') . DIRECTORY_SEPARATOR . "view_{$token}";

            if (!is_dir($this->cachePath)) {
                mkdir($this->cachePath, 0777, true);
            }
        }
    }

    public function compile($path = null)
    {
        if (app()->runningUnitTests()) {
            $this->setCachePath(ParallelTesting::token());
        }

        return parent::compile($path);
    }
}

So now when you run parallel unit testing the another processes will have their own folder where the views is stored.
I would be happy if this will help anyone who looking for separate view directory per process solution 👍♥️.

2 likes
1 reply
LaryAI's avatar

Great write-up! You’ve already covered the main approach for isolating compiled Blade views per parallel test process. Here’s a concise summary and a few extra tips to ensure smooth operation for anyone implementing this in Laravel 11+:


Summary of Solution:

  1. Replace the Default View Service Provider

In bootstrap/app.php, replace Laravel’s default ViewServiceProvider with your custom one:

->withProviders(ServiceProvider::defaultProviders()
    ->merge([])
    ->replace([\Illuminate\View\ViewServiceProvider::class => \App\Providers\ViewServiceProvider::class])
    ->toArray()
)
  1. Custom ViewServiceProvider

Extend Laravel’s provider and override the registerBladeCompiler method to bind your custom compiler:

namespace App\Providers;

use App\Services\BladeCompiler;
use Illuminate\View\DynamicComponent;

class ViewServiceProvider extends \Illuminate\View\ViewServiceProvider
{
    public function registerBladeCompiler()
    {
        $this->app->singleton('blade.compiler', function ($app) {
            return tap(new BladeCompiler(
                $app['files'],
                $app['config']['view.compiled'],
                $app['config']->get('view.relative_hash', false) ? $app->basePath() : '',
                $app['config']->get('view.cache', true),
                $app['config']->get('view.compiled_extension', 'php'),
                $app['config']->get('view.check_cache_timestamps', true),
            ), function ($blade) {
                $blade->component('dynamic-component', DynamicComponent::class);
            });
        });
    }
}
  1. Custom BladeCompiler

Override the compile method to set a process-specific cache path during parallel tests:

namespace App\Services;

use Illuminate\Support\Facades\ParallelTesting;

class BladeCompiler extends \Illuminate\View\Compilers\BladeCompiler
{
    protected function setCachePath(false|string $token): void
    {
        if (is_string($token)) {
            $this->cachePath = config('view.compiled') . DIRECTORY_SEPARATOR . "view_{$token}";

            if (!is_dir($this->cachePath)) {
                mkdir($this->cachePath, 0777, true);
            }
        }
    }

    public function compile($path = null)
    {
        if (app()->runningUnitTests()) {
            $this->setCachePath(ParallelTesting::token());
        }

        return parent::compile($path);
    }
}

Extra Tips:

  • Cleanup:
    After your parallel tests finish, you may want to clean up the per-process view cache directories. You can do this in your phpunit.xml with a processUncoveredFiles script or in a tearDown method.

  • Environment Variable:
    Make sure VIEW_COMPILED_PATH is set in your .env.testing to a writable directory (e.g., storage/framework/testing/views).

  • Permissions:
    Ensure the created directories are writable by your test runner, especially in CI environments.


Why This Works:
Laravel’s Blade compiler caches compiled views in a single directory by default. When running tests in parallel, processes can overwrite each other's compiled files, causing random failures. By isolating the cache per process (using the process token), you eliminate this race condition.


Conclusion:
Your approach is solid and should help anyone running into flaky Blade-related errors during parallel testing. Thanks for sharing it with the community!

Please or to participate in this conversation.