# Fixing Pest Sharding for Modular Laravel Applications

**Author:** Mozex | **Published:** 2026-03-24 | **Tags:** Laravel, PHP, Testing, Pest, GitHub Actions | **URL:** https://mozex.dev/blog/2-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.

<!--more-->

## 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`](https://github.com/pestphp/pest/blob/4e03cd3edbae9bb9e024660a6380ebb5832a6df9/src/Plugins/Shard.php#L196):

```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](https://github.com/pestphp/pest/commit/5def62018b19b8a9fc6bdc410f6a48925a1b9d51) narrowed the pattern from something broader, but the reasoning behind the change wasn't documented. That's part of why I [filed an issue](https://github.com/pestphp/pest/issues/1445) 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`:

```php
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
<?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:

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

The fixed version:

```php
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:

```json
{
    "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:

```bash
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](https://mozex.nbg1.your-objectstorage.com/posts/2026/03/AtuqXfnzI6JEZbOHxw8PQ2ctccKJH9IRfAUqQDvr.webp)

## 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](https://github.com/pestphp/pest/issues/1445) 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`.*