Laravel Test Suites on GitHub Actions

Laravel suites on GitHub Actions stall on cold Composer installs, per-class migrations, and serial tests. Fix all three with warp- labels and sizing.

Last verified:

Overview

A Laravel suite on GitHub Actions is usually slow for three reasons that have nothing to do with your test code: composer install starts cold on every job, the database is migrated and seeded again for each test class, and the suite runs serially on a 2 vCPU machine. Point runs-on at a WarpBuild warp- label, cache the Composer directories with WarpBuilds/cache@v1 keyed on composer.lock, load a squashed schema instead of replaying migrations, and run php artisan test --parallel on a runner with enough cores to hold the processes you ask for.

The default GitHub-hosted Linux runner on private repositories carries 2 vCPU and 8 GB of RAM, per GitHub's published runner specs (checked on 2026-08-13). Laravel's --parallel flag defaults to the number of available cores, so on that shape it spawns two PHP processes that then compete with a MySQL service container running on the same virtual machine.

WarpBuild's Linux images carry the same tooling as GitHub-hosted runners, so your PHP setup action, Composer, PHPUnit, Pest, and Artisan commands run unchanged, and the only required edit to a working workflow is the runs-on line. Labels, shapes, and regions are listed on the cloud runners documentation page.

The rest of this page gives a working workflow, a sizing table with the arithmetic against GitHub's list prices, the three bottlenecks in order of cost, and how to verify the numbers in your own repository. For the sizing method applied to a different language, see the guide to sizing runners for Python test suites.

Configuration

The workflow below runs a Laravel feature suite on warp-ubuntu-latest-x64-8x with MySQL and Redis as service containers, caches both Composer directories against the lockfile, and runs the suite in parallel.

name: laravel-tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: warp-ubuntu-latest-x64-8x
    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: laravel_test
        ports:
          - 3306:3306
        options: >-
          --health-cmd "mysqladmin ping -h 127.0.0.1"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 20
      redis:
        image: redis:7
        ports:
          - 6379:6379
        options: >-
          --health-cmd "redis-cli ping"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    env:
      DB_CONNECTION: mysql
      DB_HOST: 127.0.0.1
      DB_PORT: 3306
      DB_DATABASE: laravel_test
      DB_USERNAME: root
      DB_PASSWORD: password
      REDIS_HOST: 127.0.0.1
    steps:
      - uses: actions/checkout@v4

      - name: Set up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: "8.3"
          extensions: mbstring, intl, pdo_mysql, redis, bcmath
          coverage: none

      - name: Resolve the Composer cache directory
        id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"

      - name: Restore Composer cache and vendor
        uses: WarpBuilds/cache@v1
        with:
          path: |
            ${{ steps.composer-cache.outputs.dir }}
            vendor
          key: ${{ runner.os }}-php83-composer-${{ hashFiles('composer.lock') }}
          restore-keys: |
            ${{ runner.os }}-php83-composer-

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

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

      - name: Run the suite in parallel
        run: php artisan test --parallel --processes=6 --recreate-databases

Four details in that file are worth calling out.

WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4, so path, key, restore-keys, and the cache-hit output behave as before. Entries are scoped to the key, a version hash over the cached path list, and the branch, so adding a directory to path starts a fresh entry rather than reusing the old one. Entries expire 7 days after last use, which explains a single slow run on a branch that went quiet for a week. The full input list and the split restore and save variants are in the caching documentation.

Keep the PHP version inside the cache key, as php83 does above. Extension-linked packages compiled against 8.3 should never restore into an 8.4 job.

The restore-keys prefix carries most of the value. When composer.lock changes by one package the exact key misses, the prefix restores the newest entry on the branch, and composer install fetches only the delta instead of paying for a full cold install.

Do not run php artisan config:cache before php artisan test. Cached configuration ignores the environment variables set in phpunit.xml, which is the usual cause of a suite that passes locally and connects to the wrong database inside GitHub Actions.

Sizing

php artisan test --parallel spawns one PHP process and one database per worker, and service containers run on the same virtual machine as the job. Both draw from the same CPU and memory budget, so size the runner for processes plus database rather than processes alone.

Runner labelvCPURAMStoragePrice per minute
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032

Rates and shapes come from the pricing page and the cloud runners documentation, checked on 2026-08-13.

At the middle size the arithmetic is direct: warp-ubuntu-latest-x64-4x costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner at the same 4 vCPU and 16 GB shape, which is 33 percent lower list price (GitHub billing reference, checked 2026-08-13).

4x (4 vCPU, 16 GB). Unit-heavy suites, and feature suites run with --processes=3 so MySQL keeps a core during migration. Below three workers the parallel overhead of building per-worker databases often cancels the gain.

8x (8 vCPU, 32 GB). The common landing spot for a Laravel application with database-backed feature tests. Six workers plus MySQL plus Redis fit with headroom, and 32 GB holds six connection pools and six fixture sets without swapping.

16x (16 vCPU, 64 GB). Worth it when one job consolidates a matrix, or when a monorepo runs several packages in the same run. Measure the migration phase first, since it runs once and no runner size shortens it.

A worked cost model

Take a suite that runs 400 times a month and finishes in 10 minutes at 4 vCPU. Substitute your own measured wall clock, because parallel efficiency depends on how much of the suite waits on the database.

SetupRate per minuteCost per runCost per month (400 runs)
GitHub-hosted 4-core larger runner$0.012$0.120$48.00
warp-ubuntu-latest-x64-4x$0.008$0.080$32.00

The same wall clock at the same shape leaves $16.00 a month on the table before any change to the workflow itself.

Bottlenecks

Three things dominate a slow Laravel job, in descending order of typical cost.

Dependency install

A cold composer install resolves the lockfile, downloads every package archive, extracts each one, and writes thousands of small files. A mid-sized Laravel application pulls several hundred packages, and extraction is disk-bound rather than CPU-bound. Cache the Composer cache directory and vendor together as shown above, pass --prefer-dist so Composer takes archives instead of cloning repositories, and add --classmap-authoritative on jobs that never autoload a class outside the map. If post-autoload-dump runs package discovery and cache clears you do not need in the test job, install with --no-scripts and call the one script you actually want. The key design, the prefix rules, and the vendor-versus-cache-directory question are covered in the answer on caching Composer dependencies.

Database setup per test class

RefreshDatabase migrates once and wraps each test in a transaction, but suites that use DatabaseMigrations replay every migration per test class, and that cost grows with every migration you ever wrote. Two fixes stack. Run php artisan schema:dump --prune and commit database/schema/mysql-schema.sql, so a fresh database loads one SQL file instead of replaying hundreds of migration classes. Then let parallel workers reuse their databases between runs and pass --recreate-databases only when the schema file changes, so the per-worker build happens on schema changes rather than on every push.

Serial test execution

Without --parallel, one PHP process executes every test while the other cores idle and the database sits mostly unused. Install brianium/paratest, set --processes explicitly rather than taking the core-count default, and give the database user permission to create the per-worker databases Laravel names laravel_test_1, laravel_test_2, and so on. Tests that assume a shared cache or a fixed record ID surface as flakes at this step; move that state into ParallelTesting::setUpTestDatabase hooks or per-worker key prefixes for Redis.

Proof

Every number on this page is reproducible without taking our word for it. WarpBuild rates come from the pricing page and the cloud runners documentation, GitHub list prices link to GitHub's own billing reference with the date they were checked, and the cost model is arithmetic you can repeat with a calculator after substituting your suite's wall clock. Cost and performance statements on WarpBuild pages carry a number, a source link, and a checked-on date, and the same number appears on every surface.

To measure the workflow rather than model it, WarpBuild's CI observability reports CPU and memory from the runner agent correlated with the GitHub Actions job logs, so you can see whether six workers saturate the machine or whether four of them wait on MySQL. When a failure reproduces only inside GitHub Actions, the Action Debugger pauses the workflow and opens an SSH session on the live runner, so you can query the per-worker databases and inspect the cache state on the machine that failed instead of pushing another commit with echo statements. Snapshot runners, remote Docker builders, and an MCP server round out the same product surface.

Two adjacent pages carry the parts this one compresses. The PHP on GitHub Actions page covers extension installs and autoloader tuning in more depth, and the service containers guide covers health checks, port mapping, and the failure modes of databases that start slower than the first test.

FAQ

How do I run Laravel tests in parallel on GitHub Actions?

Install brianium/paratest and run php artisan test --parallel --processes=N, where N leaves at least one vCPU for the database service container. Laravel creates one test database per process, so grant the database user permission to create databases and let the first run build them with --recreate-databases.

Which directories should a Laravel workflow cache?

Cache the Composer cache directory reported by composer config cache-files-dir together with vendor, keyed on a hash of composer.lock with a restore-keys prefix. The cache directory holds package archives and survives a lockfile change; vendor holds the extracted tree and makes a warm install close to a no-op.

What does a Laravel suite cost to run on WarpBuild?

In the worked model above, a 10-minute run on warp-ubuntu-latest-x64-4x costs $0.080, or $32.00 across 400 runs. Substitute your suite's measured wall clock and the current rate from the pricing page.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.