Skip to content

Fixing Pest Sharding for Modular Laravel Applications

When Pest v4 dropped, one feature got my attention right away: test sharding. My CI pipelines were painfully slow, and splitting tests across parallel GitHub Actions runners seemed like the obvious fix.

I upgraded, configured the shards, pushed to CI, and waited.

Every shard ran every test. Shard 1 of 4? Full suite. Shard 3 of 4? Full suite. My "optimization" made things four times worse.

The Root Cause

After digging through Pest's source code, I tracked the problem to the allTests method in Pest\Plugins\Shard. When you pass --shard=1/4, the plugin runs --list-tests to discover every test in your suite, then splits them into chunks. Splitting logic is fine. The problem is how it parses the test list.

Here's the regex on line 196 of Shard.php:

preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);

See the problem? The regex hardcodes Tests\ as the start of the namespace. It only matches test classes under the root Tests\ namespace, the default tests/ directory.

If you're building a modular Laravel application (which I do for all my projects), your tests live under namespaces like Modules\Blog\Tests\Feature\PostTest or Modules\Admin\Tests\Unit\PolicyTest. None of those start with Tests\, so the regex matches nothing. Zero tests found means no filter gets applied, and PHPUnit runs everything.

It wasn't always this restrictive. A previous commit narrowed the pattern from something broader, but the reasoning behind the change wasn't documented. That's part of why I filed an issue instead of going straight to a PR.

Several developers confirmed the same problem in the issue comments. The Pest team has a lot on their plate, so I decided to fix it myself.

Why I Couldn't Just Extend the Class

My first thought was clean and simple: extend Pest\Plugins\Shard, override what I needed, done.

But the class is final:

final class Shard implements AddsOutput, HandlesArguments

No extending. The only option was to copy the entire 170-line class and change one regex. That's what I did.

The Plugin

Here's the complete plugin. I've placed it under App\Support for this example, but you can put it anywhere that's autoloaded. Make sure the namespace matches your actual file location and PSR-4 autoload configuration.

<?php

declare(strict_types=1);

namespace App\Support;

use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\InvalidOption;
use Pest\Plugins\Concerns\HandleArguments;
use Pest\Plugins\Parallel;
use Pest\Subscribers\EnsureShardTimingFinished;
use Pest\Subscribers\EnsureShardTimingsAreCollected;
use Pest\Subscribers\EnsureShardTimingStarted;
use Pest\TestSuite;
use PHPUnit\Event;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;

/** @see https://github.com/pestphp/pest/issues/1445 */
class PestShardPlugin implements AddsOutput, HandlesArguments, Terminable
{
    use HandleArguments;

    private const string SHARD_OPTION = 'shard';

    /**
     * @var array{index: int, total: int, testsRan: int, testsCount: int}|null
     */
    private static ?array $shard = null;

    private static bool $updateShards = false;

    private static bool $timeBalanced = false;

    private static bool $shardsOutdated = false;

    private static bool $passed = false;

    /**
     * @var array<string, float>|null
     */
    private static ?array $collectedTimings = null;

    /**
     * @var list<string>|null
     */
    private static ?array $knownTests = null;

    public function __construct(
        private readonly OutputInterface $output,
    ) {
        //
    }

    public function handleArguments(array $arguments): array
    {
        if ($this->hasArgument('--update-shards', $arguments)) {
            return $this->handleUpdateShards($arguments);
        }

        if (Parallel::isWorker() && Parallel::getGlobal('UPDATE_SHARDS') === true) {
            self::$updateShards = true;

            Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
            Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);

            return $arguments;
        }

        if (! $this->hasArgument('--shard', $arguments)) {
            return $arguments;
        }

        // @phpstan-ignore-next-line
        $input = new ArgvInput($arguments);

        ['index' => $index, 'total' => $total] = self::getShard($input);

        $arguments = $this->popArgument("--shard=$index/$total", $this->popArgument('--shard', $this->popArgument(
            "$index/$total",
            $arguments,
        )));

        /** @phpstan-ignore-next-line */
        $tests = $this->allTests($arguments);

        $timings = $this->loadShardsFile();
        if ($timings !== null) {
            $knownTests = array_values(array_filter($tests, fn (string $test): bool => isset($timings[$test])));
            $newTests = array_values(array_diff($tests, $knownTests));

            $partitions = $this->partitionByTime($knownTests, $timings, $total);

            foreach ($newTests as $i => $test) {
                $partitions[$i % $total][] = $test;
            }

            $testsToRun = $partitions[$index - 1] ?? [];
            self::$timeBalanced = true;
            self::$shardsOutdated = $newTests !== [];
        } else {
            $testsToRun = (array_chunk($tests, max(1, (int) ceil(count($tests) / $total))))[$index - 1] ?? [];
        }

        self::$shard = [
            'index' => $index,
            'total' => $total,
            'testsRan' => count($testsToRun),
            'testsCount' => count($tests),
        ];

        if ($testsToRun === []) {
            return $arguments;
        }

        return [...$arguments, '--filter', $this->buildFilterArgument($testsToRun)];
    }

    /**
     * @param  array<int, string>  $arguments
     * @return array<int, string>
     */
    private function handleUpdateShards(array $arguments): array
    {
        if ($this->hasArgument('--shard', $arguments)) {
            throw new InvalidOption('The [--update-shards] option cannot be combined with [--shard].');
        }

        $arguments = $this->popArgument('--update-shards', $arguments);

        self::$updateShards = true;

        /** @phpstan-ignore-next-line */
        self::$knownTests = $this->allTests($arguments);

        if ($this->hasArgument('--parallel', $arguments) || $this->hasArgument('-p', $arguments)) {
            Parallel::setGlobal('UPDATE_SHARDS', true);
            Parallel::setGlobal('SHARD_RUN_ID', uniqid('pest-shard-', true));
        } else {
            Event\Facade::instance()->registerSubscriber(new EnsureShardTimingStarted);
            Event\Facade::instance()->registerSubscriber(new EnsureShardTimingFinished);
        }

        return $arguments;
    }

    /**
     * @param  list<string>  $arguments
     * @return list<string>
     */
    private function allTests(array $arguments): array
    {
        $output = (new Process([
            'php',
            ...$this->removeParallelArguments($arguments),
            '--list-tests',
        ]))->setTimeout(120)->mustRun()->getOutput();

        // Fix: Allow any namespace prefix before Tests\ (not just root Tests\)
        preg_match_all('/ - (?:P\\\\)?((?:[A-Za-z0-9_]+\\\\)*Tests\\\\[^:]+)::/', $output, $matches);

        return array_values(array_unique($matches[1]));
    }

    /**
     * @param  array<int, string>  $arguments
     * @return array<int, string>
     */
    private function removeParallelArguments(array $arguments): array
    {
        return array_filter($arguments, fn (string $argument): bool => ! in_array($argument, ['--parallel', '-p'], strict: true));
    }

    private function buildFilterArgument(mixed $testsToRun): string
    {
        return addslashes(implode('|', $testsToRun));
    }

    public function addOutput(int $exitCode): int
    {
        self::$passed = $exitCode === 0;

        if (self::$updateShards && self::$passed && ! Parallel::isWorker()) {
            self::$collectedTimings = $this->collectTimings();

            $count = self::$knownTests !== null
                ? count(array_intersect_key(self::$collectedTimings, array_flip(self::$knownTests)))
                : count(self::$collectedTimings);

            $this->output->writeln(sprintf(
                '  <fg=gray>Shards:</>   <fg=default>shards.json updated with timings for %d test class%s.</>',
                $count,
                $count === 1 ? '' : 'es',
            ));
        }

        if (self::$shard === null) {
            return $exitCode;
        }

        [
            'index' => $index,
            'total' => $total,
            'testsRan' => $testsRan,
            'testsCount' => $testsCount,
        ] = self::$shard;

        $this->output->writeln(sprintf(
            '  <fg=gray>Shard:</>    <fg=default>%d of %d</> — %d file%s ran, out of %d%s.',
            $index,
            $total,
            $testsRan,
            $testsRan === 1 ? '' : 's',
            $testsCount,
            self::$timeBalanced ? ' <fg=gray>(time-balanced)</>' : '',
        ));

        if (self::$shardsOutdated) {
            $this->output->writeln('  <fg=yellow;options=bold>WARN</>  <fg=default>The [tests/.pest/shards.json] file is out of date. Run [--update-shards] to update it.</>');
        }

        return $exitCode;
    }

    public function terminate(): void
    {
        if (! self::$updateShards) {
            return;
        }

        if (Parallel::isWorker()) {
            $this->writeWorkerTimings();

            return;
        }

        if (! self::$passed) {
            return;
        }

        $timings = self::$collectedTimings ?? $this->collectTimings();

        if ($timings === []) {
            return;
        }

        $this->writeTimings($timings);
    }

    /**
     * @return array<string, float>
     */
    private function collectTimings(): array
    {
        $runId = Parallel::getGlobal('SHARD_RUN_ID');

        if (is_string($runId)) {
            return $this->readWorkerTimings($runId);
        }

        return EnsureShardTimingsAreCollected::timings();
    }

    private function writeWorkerTimings(): void
    {
        $timings = EnsureShardTimingsAreCollected::timings();

        if ($timings === []) {
            return;
        }

        $runId = Parallel::getGlobal('SHARD_RUN_ID');

        if (! is_string($runId)) {
            return;
        }

        $path = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-'.getmypid().'.json';

        file_put_contents($path, json_encode($timings, JSON_THROW_ON_ERROR));
    }

    /**
     * @return array<string, float>
     */
    private function readWorkerTimings(string $runId): array
    {
        $pattern = sys_get_temp_dir().DIRECTORY_SEPARATOR.'__pest_sharding_'.$runId.'-*.json';
        $files = glob($pattern);

        if ($files === false || $files === []) {
            return [];
        }

        $merged = [];

        foreach ($files as $file) {
            $contents = file_get_contents($file);

            if ($contents === false) {
                continue;
            }

            $timings = json_decode($contents, true);

            if (is_array($timings)) {
                $merged = array_merge($merged, $timings);
            }

            unlink($file);
        }

        return $merged;
    }

    private function shardsPath(): string
    {
        $testSuite = TestSuite::getInstance();

        return implode(DIRECTORY_SEPARATOR, [$testSuite->rootPath, $testSuite->testPath, '.pest', 'shards.json']);
    }

    /**
     * @return array<string, float>|null
     */
    private function loadShardsFile(): ?array
    {
        $path = $this->shardsPath();

        if (! file_exists($path)) {
            return null;
        }

        $contents = file_get_contents($path);

        if ($contents === false) {
            throw new InvalidOption('The [tests/.pest/shards.json] file could not be read. Delete it or run [--update-shards] to regenerate.');
        }

        $data = json_decode($contents, true);

        if (! is_array($data) || ! isset($data['timings']) || ! is_array($data['timings'])) {
            throw new InvalidOption('The [tests/.pest/shards.json] file is corrupted. Delete it or run [--update-shards] to regenerate.');
        }

        return $data['timings'];
    }

    /**
     * @param  list<string>  $tests
     * @param  array<string, float>  $timings
     * @return list<list<string>>
     */
    private function partitionByTime(array $tests, array $timings, int $total): array
    {
        $knownTimings = array_filter(
            array_map(fn (string $test): ?float => $timings[$test] ?? null, $tests),
            fn (?float $t): bool => $t !== null,
        );

        $median = $knownTimings !== [] ? $this->median(array_values($knownTimings)) : 1.0;

        $testsWithTimings = array_map(
            fn (string $test): array => ['test' => $test, 'time' => $timings[$test] ?? $median],
            $tests,
        );

        usort($testsWithTimings, fn (array $a, array $b): int => $b['time'] <=> $a['time']);

        /** @var list<list<string>> */
        $bins = array_fill(0, $total, []);
        /** @var non-empty-list<float> */
        $binTimes = array_fill(0, $total, 0.0);

        foreach ($testsWithTimings as $item) {
            $minIndex = array_search(min($binTimes), $binTimes, strict: true);
            assert(is_int($minIndex));

            $bins[$minIndex][] = $item['test'];
            $binTimes[$minIndex] += $item['time'];
        }

        return $bins;
    }

    /**
     * @param  list<float>  $values
     */
    private function median(array $values): float
    {
        sort($values);

        $count = count($values);
        $middle = (int) floor($count / 2);

        if ($count % 2 === 0) {
            return ($values[$middle - 1] + $values[$middle]) / 2;
        }

        return $values[$middle];
    }

    /**
     * @param  array<string, float>  $timings
     */
    private function writeTimings(array $timings): void
    {
        $path = $this->shardsPath();

        $directory = dirname($path);
        if (! is_dir($directory)) {
            mkdir($directory, 0755, true);
        }

        if (self::$knownTests !== null) {
            $knownSet = array_flip(self::$knownTests);
            $timings = array_intersect_key($timings, $knownSet);
        }

        ksort($timings);

        $canonical = self::$knownTests ?? array_keys($timings);
        sort($canonical);

        file_put_contents($path, json_encode([
            'timings' => $timings,
            'checksum' => md5(implode("\n", $canonical)),
            'updated_at' => date('c'),
        ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
    }

    /**
     * @return array{index: int, total: int}
     */
    public static function getShard(InputInterface $input): array
    {
        if ($input->hasParameterOption('--'.self::SHARD_OPTION)) {
            $shard = $input->getParameterOption('--'.self::SHARD_OPTION);
        } else {
            $shard = null;
        }

        if (! is_string($shard) || ! preg_match('/^\d+\/\d+$/', $shard)) {
            throw new InvalidOption('The [--shard] option must be in the format "index/total".');
        }

        [$index, $total] = explode('/', $shard);

        if (! is_numeric($index) || ! is_numeric($total)) {
            throw new InvalidOption('The [--shard] option must be in the format "index/total".');
        }

        if ($index <= 0 || $total <= 0 || $index > $total) {
            throw new InvalidOption('The [--shard] option index must be a non-negative integer less than the total number of shards.');
        }

        $index = (int) $index;
        $total = (int) $total;

        return [
            'index' => $index,
            'total' => $total,
        ];
    }
}

What Changed

Every line is identical to Pest's built-in Shard plugin except for one line in the allTests method.

The original regex:

preg_match_all('/ - (?:P\\\\)?(Tests\\\\[^:]+)::/', $output, $matches);

The fixed version:

preg_match_all('/ - (?:P\\\\)?((?:[A-Za-z0-9_]+\\\\)*Tests\\\\[^:]+)::/', $output, $matches);

Adding (?:[A-Za-z0-9_]+\\\\)* allows zero or more namespace segments before Tests\. So Tests\Feature\UserTest, Modules\Blog\Tests\Feature\PostTest, and Domain\Billing\Tests\Unit\InvoiceTest all get picked up correctly.

Registering the Plugin

Once you've placed the class somewhere in your project, register it in your composer.json under the extra section:

{
    "extra": {
        "pest": {
            "plugins": [
                "App\\Support\\PestShardPlugin"
            ]
        }
    }
}

Replace App\\Support\\PestShardPlugin with the fully qualified class name that matches your file's namespace and location. If your project uses a modular structure, the namespace might be something like Modules\Shared\Support\PestShardPlugin. Just make sure it matches your PSR-4 autoload mapping.

Run composer dump-autoload after making the change.

That's it. Your shards will now correctly split tests across runners:

php artisan test --shard=1/4
php artisan test --shard=2/4
php artisan test --shard=3/4
php artisan test --shard=4/4

Here's what it looks like in a real GitHub Actions pipeline with sharding working correctly across a modular codebase:

GitHub Actions workflow showing parallel test shards completing successfully

On final Classes in Open-Source Packages

This experience reinforced something I've felt for a while about using final in open-source code.

I understand the reasoning. final prevents fragile subclasses that break on internal changes. It communicates "this is not designed for extension." Valid arguments, both of them.

But here's what happens in practice: a class has a bug, the class is final, and every user is stuck. You can't extend it. You can't override the broken behavior. Your only option is to copy the entire class, all 170 lines of it, just to fix one regex pattern. That's a lot of duplicated code you now maintain independently, code that won't benefit from future upstream improvements.

Without final, I could have extended the class and replaced only what needed fixing. Less code to maintain, and the rest of the sharding logic would still come from the package automatically.

I'm not saying final is never appropriate. For value objects, for classes where incorrect extension would create security problems, it makes sense. But for plugin-like classes in open-source packages, especially ones that already implement interfaces defining the public contract, I think leaving them open gives the community a practical way to help themselves while waiting for official fixes.

This is just my perspective. The Pest team has their own design philosophy, and reasonable developers can disagree on where to draw this line.

This Is Temporary

I want to be clear: this plugin is a workaround. The underlying issue is in Pest's core, and the proper fix belongs there. When the Pest team addresses it (and based on the community interest in the issue, I expect they will), you should remove this plugin and switch back to the built-in sharding.

Until then, if you're running a modular Laravel application and need CI sharding to work, this gets the job done.


PS (updated): Since this post was first published, Pest's built-in Shard plugin has grown to include time-balanced sharding via a shards.json file and an --update-shards command that records per-class timings to distribute tests more evenly across runners. The plugin above has been updated to mirror the current upstream class, so you get all of that new functionality alongside the namespace fix. The only logic change vs. upstream is still the one regex inside allTests.

Share this post

Share on X
Share on LinkedIn
Share on Reddit
Share on Hacker News
Copy link

Stay in the Loop

Get the latest posts delivered to your inbox - on your schedule.

No spam. Unsubscribe anytime.

Scroll to top