Making Pest --parallel and time-based sharding work in Bitbucket Pipelines

How to set up Pest 4.6's time-based shard distribution in Bitbucket Pipelines, including the gotchas around composer plugins, root permissions, and the missing matrix feature.

Zacharias Creutznacher Zacharias Creutznacher

Our main application has over 1500 tests and 200+ migrations. Running the full suite in CI takes roughly 24 minutes on Bitbucket's default 2-core runners. That is 24 minutes of a developer staring at a pipeline, waiting for a green checkmark. When Pest 4.6 shipped time-based shard distribution, we jumped on it. What followed was a half-day detour through Bitbucket-specific quirks that are not covered anywhere in the Pest docs (which focus on GitHub Actions). This post documents the setup and every trap we stepped into so you can skip the detour.

#What time-based sharding does

Before 4.6, Pest's --shard flag split tests by file count. Four shards, forty test files, ten files each. Sounds fair until you realize one file contains a 180-second integration test and the other nine finish in two seconds. One shard runs for three minutes while the other three are done in twenty seconds.

Time-based sharding fixes this. You record actual execution times into a tests/.pest/shards.json file, commit it, and Pest uses those timings to balance shards by wall-clock duration instead of file count. The result: all shards finish at roughly the same time.

#Step 1: Generate the timing data

Run your full suite once with --update-shards:

 1./vendor/bin/pest --parallel --update-shards

This produces tests/.pest/shards.json:

 1{
 2    "timings": {
 3        "Tests\\Feature\\Services\\FormDefinitionServiceTest": 184.21,
 4        "Tests\\Feature\\Controllers\\CustomerControllerTest": 25.67,
 5        "Tests\\Unit\\Models\\DocumentTypeTest": 0.13
 6    },
 7    "checksum": "04b787735efd96b1967f87a1826ac789",
 8    "updated_at": "2026-05-22T16:48:37+02:00"
 9}

Commit this file. When new tests are added that are not in the JSON, Pest falls back to even distribution for those files and prints a warning. No tests are skipped or lost.

If you want a convenient way to regenerate the timings later, add a composer script:

 1{
 2    "scripts": {
 3        "test:update-shards": "php -d memory_limit=2G vendor/bin/pest --parallel --update-shards --colors=always"
 4    }
 5}

#Step 2: The pipeline (Bitbucket does not have matrix)

In GitHub Actions you would write a matrix strategy and be done in five lines. Bitbucket Pipelines does not have a matrix feature. What it does have is automatic environment variables for parallel steps:

  • BITBUCKET_PARALLEL_STEP (zero-based index of the current step in its parallel group)
  • BITBUCKET_PARALLEL_STEP_COUNT (total number of steps in the parallel group)

The trick: define one YAML anchor for the test step and reference it multiple times inside a parallel: block. Each copy gets a unique index from Bitbucket. The shard value is computed from that index:

 1definitions:
 2  steps:
 3    - step: &test
 4        name: tests
 5        services:
 6          - mysql
 7          - redis
 8        script:
 9          - SHARD="$((BITBUCKET_PARALLEL_STEP + 1))/${BITBUCKET_PARALLEL_STEP_COUNT}"
10          - cp .env.pipeline .env
11          - php artisan migrate:fresh --database=test --seed
12          - ./vendor/bin/pest --parallel --shard="${SHARD}"
13
14pipelines:
15  pull-requests:
16    '**':
17      - step: *install
18      - parallel:
19          - step: *phpcs
20          - step: *phpstan
21          - step: *rector
22      - parallel:
23          - step: *test
24          - step: *test
25          - step: *test
26          - step: *test

Want more shards? Add another - step: *test line. The shard count adjusts automatically because it reads BITBUCKET_PARALLEL_STEP_COUNT. No hardcoded numbers anywhere.

Important: the test shards must be in their own parallel: group, separate from your linting/analysis steps. Otherwise BITBUCKET_PARALLEL_STEP_COUNT would include those steps in the count and the shard math breaks. The tradeoff: tests start after linting completes (sequential), not alongside it. In practice this is fine, if a lint check fails you save all the test-minutes.

#The gotchas (the part you actually need)

The Pest docs show a clean three-step process. In Bitbucket, three things conspire to break --parallel and --shard silently.

#1. Composer's --no-plugins flag

Many Bitbucket pipelines use composer install --no-plugins --no-scripts for speed and security. The problem: pestphp/pest-plugin is a Composer plugin (type composer-plugin). It subscribes to the post-autoload-dump event and writes a file called vendor/pest-plugins.json that registers all Pest plugins, including the ones that provide --parallel, --shard, and --update-shards.

With --no-plugins, this file never gets created. Pest starts without knowing about any plugins, and you get:

 1INFO  Unknown option "--parallel". Most similar options are --all, --filter, --group, --help, --version.

Fix: remove --no-plugins from your composer install. The allow-plugins config in your composer.json already controls which plugins are allowed to run:

 1{
 2    "config": {
 3        "allow-plugins": {
 4            "pestphp/pest-plugin": true
 5        }
 6    }
 7}

#2. Running as root (the default in Bitbucket)

Bitbucket Pipeline containers run as root. Composer refuses to load plugins as root unless you explicitly opt in. Even after removing --no-plugins, you will see:

 1Do not run Composer as root/super user!
 2Aborting as no plugin should be loaded if running as super user is not explicitly allowed

Fix: set COMPOSER_ALLOW_SUPERUSER=1 inline on your Composer commands:

 1- COMPOSER_ALLOW_SUPERUSER=1 composer install --no-interaction --no-progress --ansi --optimize-autoloader --no-scripts

#3. Ensuring pest-plugins.json exists

Even with the above fixes, depending on your Composer version the plugin event might not fire during install when --no-scripts is set. Belt and suspenders: call pest:dump-plugins explicitly after install:

 1- COMPOSER_ALLOW_SUPERUSER=1 composer install --no-interaction --no-progress --ansi --optimize-autoloader --no-scripts
 2- COMPOSER_ALLOW_SUPERUSER=1 composer pest:dump-plugins

This is a Composer command registered by the Pest plugin. It scans all installed packages for extra.pest.plugins entries and writes vendor/pest-plugins.json. If the plugin loaded correctly during install, this is a no-op. If it did not, this line saves you.

#The complete install step

Putting it all together:

 1- step: &install
 2    name: Install Dependencies
 3    script:
 4      - COMPOSER_ALLOW_SUPERUSER=1 composer install --no-interaction --no-progress --ansi --optimize-autoloader --no-scripts
 5      - COMPOSER_ALLOW_SUPERUSER=1 composer pest:dump-plugins
 6    artifacts:
 7      - vendor/**

#Results

Bitbucket's default runners have 2 CPU cores. On our local machines with 10 cores, --parallel chews through 1500 tests in about seven minutes. In the pipeline, that same suite took 24 minutes on a single step.

After sharding into four parallel test steps:

  • Install: ~2 min
  • Lint (parallel): ~3 min
  • Tests (4 shards, parallel): ~7 min each
  • Total: ~12 min

That cuts the pipeline time roughly in half. And it scales further: if you upgrade to Bitbucket's larger runners with more cores, each shard runs --parallel internally with more processes, stacking both optimizations. More shards, more cores per shard, shorter pipelines.

#Keeping it fresh

After adding or removing a significant number of tests, regenerate the timings:

 1composer test:update-shards

Commit the updated shards.json. Pest handles staleness gracefully (new files get even distribution, deleted files are ignored), but fresh timings give you the best balance.

#Key takeaways

  1. Time-based sharding in Pest 4.6+ balances CI jobs by actual execution time, not file count.
  2. Bitbucket has no matrix feature, but BITBUCKET_PARALLEL_STEP and BITBUCKET_PARALLEL_STEP_COUNT give you the same result with a single YAML anchor.
  3. Three things break Pest plugins in Bitbucket: --no-plugins on composer install, running as root without COMPOSER_ALLOW_SUPERUSER=1, and --no-scripts potentially suppressing the plugin event. Fix all three and you are set.
  4. composer pest:dump-plugins is your safety net. Call it after install, and vendor/pest-plugins.json will always be there.