GitHub Actions + Pest Sharding — Cut Laravel CI from 12 Minutes to 3

Cut Laravel CI from 12 minutes to 3 with Pest sharding on GitHub Actions — matrix shards, time-balanced distribution, per-shard Postgres, and a status gate.

Steven Richardson
Steven Richardson
· 8 min read

Our main Laravel app's CI had crept up to 12 minutes per push. --parallel was already on, but it tops out at the runner's CPU count — and GitHub's standard runners give you 4 vCPUs. The fix was horizontal: Pest sharding on GitHub Actions, splitting the suite across four matrix jobs that each run a quarter of the tests.

This is the complete setup — matrix strategy, per-shard databases, time-balanced distribution, caching, and a single status check to gate merges on.

Audit the baseline CI time#

Before sharding anything, capture your current numbers so you can prove the improvement. Open your repository's Actions tab, find the last ten runs of your test workflow, and note the wall-clock time of the test job — not the whole workflow. Then break it down locally: run the suite once and note how long setup (checkout, PHP, Composer, migrations) takes versus the tests themselves.

time ./vendor/bin/pest --parallel

This split matters. Sharding only divides test time. If your job spends 90 seconds on setup and 60 on tests, four shards make it slower, not faster — every shard pays the full setup cost. Our suite spent roughly 70 seconds on setup and 10+ minutes running tests (a handful of heavy feature tests and architecture tests dominate), which made it an ideal candidate.

Add the GitHub Actions matrix strategy for four shards#

Define the shard indexes as a matrix in your workflow, which makes GitHub spin up one independent job per index. The same technique drives matrix testing across PHP and database versions — here the axis is the shard number instead of the PHP version.

# .github/workflows/tests.yml
name: tests

on:
  push:
    branches: [production, develop]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      fail-fast: false # let every shard finish so you see all failures
      matrix:
        shard: [1, 2, 3, 4]

    name: "Tests (shard ${{ matrix.shard }}/4)"

fail-fast: false is deliberate. The default cancels sibling shards when one fails, which sounds efficient but hides failures that live on other shards — you fix one test, push, and discover the next failure 12 minutes later.

Wire Pest sharding into the test command#

Pass the matrix index into Pest's --shard flag so each job runs only its slice of the suite. The flag takes N/M — this shard's number over the total shard count. Keep --parallel in the command: sharding distributes tests across machines while parallel distributes them across processes on one machine, and they stack.

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          tools: composer:v2
          coverage: none # xdebug off unless you collect coverage

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist --optimize-autoloader

      - name: Prepare application
        run: |
          cp .env.example .env
          php artisan key:generate

      - name: Run test shard
        run: ./vendor/bin/pest --ci --parallel --shard=${{ matrix.shard }}/4

By default Pest splits by file count — each shard gets roughly the same number of test files. That's fine to start with; we'll fix the balance problem in a moment.

Provision an isolated Postgres per shard#

Add a services block to the job so every matrix job boots its own throwaway Postgres container. Because the matrix runs four copies of the job, you automatically get four databases — no shared state, no concurrent migrations stomping on each other, and RefreshDatabase behaves exactly as it does locally.

    services:
      postgres:
        image: postgres:17
        env:
          POSTGRES_USER: laravel
          POSTGRES_PASSWORD: secret
          POSTGRES_DB: testing
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U laravel"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10

Point the test step at it with env vars:

      - name: Run test shard
        env:
          DB_CONNECTION: pgsql
          DB_HOST: 127.0.0.1
          DB_PORT: 5432
          DB_DATABASE: testing
          DB_USERNAME: laravel
          DB_PASSWORD: secret
        run: ./vendor/bin/pest --ci --parallel --shard=${{ matrix.shard }}/4

The health check options matter. Without them the job can reach the test step before Postgres accepts connections, and you get flaky connection refused failures that have nothing to do with your code.

Enable time-balanced Pest sharding#

Generate timing data locally with --update-shards, then commit the resulting JSON file — when it exists, --shard switches from file-count distribution to time-balanced distribution automatically. No extra CI flag needed.

# run the full suite once, recording per-class durations
./vendor/bin/pest --parallel --update-shards

# commit the timing file
git add tests/.pest/shards.json
git commit -m "chore: record pest shard timings"

This fixes the slow-shard tax. With file-count splitting, the shard that happens to hold your Stripe feature tests finishes in four minutes while the unit-test shard finishes in 40 seconds — and your CI is only as fast as the slowest shard. Time balancing distributes by recorded duration so all four finish within seconds of each other. Pest confirms it in the output:

Shard:    1 of 4 — 31 files ran, out of 124 (time-balanced).

When you add or rename test files, the timing data goes stale. Tests still run — new files are spread evenly across shards — but Pest prints a warning. Refresh it weekly with a scheduled workflow:

name: Update Shards

on:
  workflow_dispatch:
  schedule:
    - cron: '0 0 * * 1' # weekly on Monday

jobs:
  update-shards:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
      - run: composer install --no-interaction --prefer-dist
      - run: ./vendor/bin/pest --parallel --update-shards
      - name: Commit changes
        run: |
          git config user.name "github-actions"
          git config user.email "[email protected]"
          git add tests/.pest/shards.json
          git commit -m "chore: update shards.json" || true
          git push

Cache Composer dependencies across shards#

Add a cache step keyed on composer.lock so all four shards pull the same vendor directory instead of resolving packages four times. The matrix multiplies your install cost by the shard count, so this is the difference between 4 × 60 seconds of composer install and 4 × 8 seconds of cache restore.

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ runner.os }}-${{ hashFiles('composer.lock') }}

Do not put the shard index in the cache key. Every shard installs identical dependencies, so a shared key means shard 1's cache write serves shards 2–4 on the next run. A per-shard key just stores four copies of the same vendor directory and quarters your hit rate. Same logic applies to node_modules if your tests need built assets — key it on package-lock.json only.

Gate the workflow on all shards#

Add a final job that depends on the whole matrix, and point your branch protection rule at it. Without this, you'd need to list all four shard jobs as required status checks — and update the rule every time you change the shard count.

  tests-passed:
    name: All tests passed
    needs: test
    runs-on: ubuntu-latest
    if: always() # run even when a shard fails, so the gate reports
    steps:
      - name: Check matrix result
        run: |
          if [ "${{ needs.test.result }}" != "success" ]; then
            echo "One or more shards failed."
            exit 1
          fi

needs.test.result collapses the entire matrix into one value — it's only success when every shard passed. Make All tests passed the single required check and the shard count becomes an implementation detail you can change freely.

The result on our suite:

Setup Wall-clock CI time
Single job, --parallel only 12m 04s
4 shards, file-count split 5m 41s
4 shards, time-balanced 3m 12s

Gotchas and Edge Cases#

Tiny suites get slower. Below roughly two minutes of test time, the per-shard setup overhead eats the win. Shard when test time dominates setup time, not before.

Billing multiplies. Four shards burn four jobs' worth of GitHub Actions minutes for roughly the same total compute. On private repos you're paying for the speed-up in minutes consumed — usually worth it, but know the trade.

Browser tests want their own shard count maths. If you've adopted Pest 4 browser testing with Playwright, those tests are an order of magnitude slower than unit tests. Time-balanced sharding handles the skew, but only after --update-shards has recorded the real durations — regenerate the timing file after adding any browser test.

Snapshot artifacts live per shard. Failed snapshot tests write their diffs on whichever runner executed them. Upload tests/.pest/snapshots as a per-shard artifact if you need to inspect failures, or you'll be re-running locally to see what changed.

Coverage needs merging. Each shard produces a partial coverage report. Upload each one (Codecov and similar services merge multiple uploads for the same commit automatically), or collect clover files as artifacts and merge with phpcov. Don't enable Xdebug on shards that don't collect coverage — it roughly doubles test time.

A corrupted shards.json stops the run. Pest exits with a clear error rather than guessing. Delete the file or re-run --update-shards to regenerate it.

Wrapping Up#

Shard when your test time meaningfully exceeds your setup time, commit tests/.pest/shards.json so the shards stay balanced, and gate merges on the single tests-passed check. From here, tighten the suite itself with type-safe Pest tests via PestStan, and make the green build do something useful with zero-downtime deployment from GitHub Actions to Forge.

FAQ#

What is test sharding in Pest 4?

Sharding splits a test suite into N groups that run on separate machines. You pass --shard=2/4 and Pest runs only the second quarter of the suite on that runner. Combined with a GitHub Actions matrix, four runners each execute a quarter of your tests simultaneously, cutting wall-clock CI time to roughly the slowest shard's duration.

How does time-based sharding work in Pest 4.6?

You run the full suite once with --update-shards, which records each test class's duration into tests/.pest/shards.json. After committing that file, any --shard run automatically distributes tests so every shard takes roughly equal wall-clock time, rather than an equal file count. There is no separate CI flag — the presence of the timing file activates it.

Can I combine --parallel and --shard in Pest?

Yes, and you should. --shard distributes test files across machines, while --parallel distributes them across CPU processes within one machine. They operate at different layers, so ./vendor/bin/pest --parallel --shard=1/4 gives each of your four runners full use of its own CPUs.

How many shards should I use on GitHub Actions?

Start with the smallest count that brings test time below your setup time multiplied by two — for most mid-sized Laravel suites that's 3–5 shards. More shards mean more duplicated setup cost and more billed minutes, with diminishing returns once each shard's test time approaches its setup time.

Does sharding work with RefreshDatabase?

Yes. Each matrix job runs in its own runner with its own database service container, so shards never share state and RefreshDatabase migrates each shard's database independently. Within a shard, --parallel uses Laravel's parallel testing support, which creates a separate test database per process.

How do I aggregate test results across shards?

For pass/fail, a gate job with needs: [test] collapses the matrix into a single status check. For coverage, upload each shard's report — services like Codecov automatically merge multiple uploads for one commit — or store clover XML files as artifacts and merge them with phpcov in a downstream job.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.