Skip to content
compiler.dev

Speed Up Laravel in GitHub Actions

Last updated

To speed up Laravel in GitHub Actions, cache Composer's download directory, run tests in parallel with php artisan test --parallel, and give your MySQL or Postgres service a health check and fast test settings. The biggest wins are usually parallel tests and not installing the same packages on every run.

This guide covers each piece with a complete workflow. For the general approach, see our guide to speeding up GitHub Actions.

Where the time goes

Read the step timings from a recent run. A Laravel pipeline normally spends time on: PHP and extension setup, composer install, npm ci plus vite build, database migration, and the test run. Tests with RefreshDatabase and many feature tests are the usual long pole.

PHP setup and the Composer cache

shivammathur/setup-php installs PHP and extensions quickly on hosted runners. Its README uses the v2 tag and has inputs php-version, extensions, coverage and tools. Set coverage: none unless you need coverage, because Xdebug slows PHP a lot.

The README's Composer caching recipe stores Composer's cache directory, not vendor, and warns that caching vendor with actions/cache can cause side effects:

- uses: shivammathur/setup-php@v2
  with:
    php-version: "8.4"
    extensions: mbstring, pdo_mysql, bcmath
    coverage: none
- name: Get Composer cache directory
  id: composer-cache
  run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v6
  with:
    path: ${{ steps.composer-cache.outputs.dir }}
    key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
    restore-keys: ${{ runner.os }}-composer-
- run: composer install --prefer-dist --no-progress --no-interaction

Also pass --no-scripts if post-install scripts do work you do not need in CI, and run php artisan package:discover yourself only if tests need it. Skip --dev removal in CI: you need the dev packages for tests.

Run tests in parallel

Laravel's php artisan test --parallel uses the brianium/paratest package to run tests in several processes. Install it as a dev dependency (composer require brianium/paratest --dev). Laravel creates one database per process, named from your test database with a token suffix, so processes do not collide.

php artisan test --parallel --processes=4

With Pest, ./vendor/bin/pest --parallel does the same. Without --processes, it uses the number of CPU cores. Parallel tests need isolated state: no shared files in storage, no fixed ports, and no tests that depend on running order. Use RefreshDatabase or DatabaseTransactions rather than leaving data around. If a test fails only in parallel mode, shared state is the first suspect.

A tuned database service

Use the same database you run in production. For MySQL, a service container with a health check:

services:
  mysql:
    image: mysql:8.4
    env:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: laravel
    ports: ["3306:3306"]
    options: >-
      --health-cmd "mysqladmin ping -h 127.0.0.1 -ppassword"
      --health-interval 5s --health-timeout 5s --health-retries 15

MySQL tuning for throwaway test data is a legitimate CI-only trade. The container image accepts server flags as arguments in docker run, but GitHub's services block does not support passing a command. Two practical options: start MySQL yourself with docker run in a step and add flags such as --innodb-flush-log-at-trx-commit=0 --sync-binlog=0, or put the database on tmpfs by adding --tmpfs /var/lib/mysql to options. The tmpfs approach is simple and removes disk waits entirely. Never use either on real data.

SQLite in memory is much faster, but it can hide differences from MySQL (JSON functions, collation, foreign key behavior). Use it for quick unit jobs if you like, and keep a MySQL job as the gate.

Frontend assets

If any test renders pages that need Vite assets, build them once. Cache npm through actions/setup-node with cache: npm, run npm ci and npm run build, and pass the public/build folder to later jobs as an artifact instead of rebuilding in each shard. Pure API test jobs should skip the Node steps entirely.

Static analysis and style in their own jobs

Larastan (PHPStan), Pint and Rector do not depend on the database. Run them as separate jobs so they overlap with the test job. Pint can run with --test to check without changing files. PHPStan has a result cache directory; caching it with actions/cache makes repeat runs faster.

A full workflow

name: tests
on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: tests-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_ROOT_PASSWORD: password
          MYSQL_DATABASE: laravel
        ports: ["3306:3306"]
        options: >-
          --tmpfs /var/lib/mysql
          --health-cmd "mysqladmin ping -h 127.0.0.1 -ppassword"
          --health-interval 5s --health-timeout 5s --health-retries 15
    env:
      DB_CONNECTION: mysql
      DB_HOST: 127.0.0.1
      DB_DATABASE: laravel
      DB_USERNAME: root
      DB_PASSWORD: password
    steps:
      - uses: actions/checkout@v7
      - uses: shivammathur/setup-php@v2
        with:
          php-version: "8.4"
          extensions: mbstring, pdo_mysql, bcmath
          coverage: none
      - id: composer-cache
        run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
      - uses: actions/cache@v6
        with:
          path: ${{ steps.composer-cache.outputs.dir }}
          key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
          restore-keys: ${{ runner.os }}-composer-
      - run: composer install --prefer-dist --no-progress --no-interaction
      - run: cp .env.example .env && php artisan key:generate
      - run: php artisan test --parallel

What more CPU cores buy you

--parallel defaults to your core count, so a 2-vCPU hosted runner gives two processes and an 8-vCPU machine gives eight. MySQL runs on the same machine, so it competes for cores; with tmpfs the database mostly uses CPU and memory, which scales well. We have no PHP or Laravel row in our benchmarks, so we do not quote a number. The Turborepo + Next.js + tRPC + MySQL stack (3m29s to 1m53s, 1.9x, 8 October 2026) is a different framework, though it also used MySQL.

To test your suite, change one line to runs-on: [self-hosted, Linux, compiler-ci]; see pricing and compare. Related: the Laravel template, GitHub Actions caching and test sharding.

FAQ

How do I run Laravel tests in parallel?

Install brianium/paratest as a dev dependency and run php artisan test --parallel, or pest --parallel with Pest. Each process gets its own database.

Should I cache vendor in GitHub Actions?

No. Cache Composer's download cache and run composer install. The setup-php README warns that caching vendor can have side effects.

Is Xdebug slowing my CI?

Yes if it is loaded. Set coverage: none in setup-php unless the job produces coverage, and use pcov when you need it.

Why do parallel Laravel tests fail only in CI?

Usually shared state: files in storage, cached values, or data not cleaned between tests. Run the failing test alone, then with --parallel, to confirm.

Run this workflow on compiler.dev 200 free minutes

Start free