Faster PHP Builds on GitHub Actions

PHP builds on GitHub Actions stall on cold Composer installs and 2 vCPU runners. Fix both with warp- runner labels, WarpBuilds/cache, and correct sizing.

Last verified:

Overview

PHP builds on GitHub Actions spend most of their wall clock repeating work that never changes between runs: a cold vendor directory, PHP extensions compiled at job start, an autoloader dumped from scratch, and a test database migrated and seeded before the first assertion. Point runs-on at a WarpBuild warp- label, cache the Composer directories with WarpBuilds/cache@v1 keyed on composer.lock, and size the runner to the number of PHPUnit or Pest processes you actually run.

The default GitHub-hosted Linux runner on private repositories carries 2 vCPUs and 8GB of RAM, per GitHub's published runner specs (checked on 2026-08-13). A PHPUnit suite launched with four parallel processes oversubscribes that machine before MySQL and Redis start. Service containers make the squeeze tighter, because they run on the same virtual machine as the job and draw from the same CPU and memory budget as the test processes.

WarpBuild's Linux images carry the same tooling as GitHub-hosted runners, so your PHP setup action, Composer, PHPUnit, Pest, and Paratest all run unchanged, and the only required edit to a working workflow is the runs-on line. Runners are ephemeral virtual machines, allocated per job and destroyed when the job ends. The full label list, shapes, and rates are on the cloud runners documentation page.

The rest of this page walks through a working Composer cache configuration, sizing for PHPUnit and Pest suites that carry MySQL and Redis service containers, the four bottlenecks that dominate PHP jobs, and how to check the numbers yourself. For the language-neutral version of the same exercise, read the checklist for speeding up GitHub Actions.

Configuration

WarpBuild maintains drop-in replacements for several upstream setup-* actions, including WarpBuilds/setup-node@v6, setup-python@v6, setup-go@v6, setup-java@v5, setup-dotnet@v4, [email protected], rust-cache@v2, gradle-actions/setup-gradle@v5, mise-action@v2, and setup-zig@v2. Those forks route toolchain and dependency caching through the WarpBuild cache with no extra configuration. PHP has no fork in that list, so a PHP workflow keeps whichever setup action it already uses and calls WarpBuilds/cache@v1 directly for the Composer directories.

The workflow below runs a PHPUnit suite on warp-ubuntu-latest-x64-8x (8 vCPUs, 32GB of RAM) and caches both Composer directories against the lockfile.

name: php-tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  phpunit:
    runs-on: warp-ubuntu-latest-x64-8x
    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
        id: composer-restore
        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: Run tests
        run: vendor/bin/paratest --processes=8

WarpBuilds/cache@v1 is a drop-in replacement for actions/cache@v4, so path, key, restore-keys, fail-on-cache-miss, and the cache-hit output behave exactly as they did before. Three properties change how you write PHP cache keys.

Entries are scoped to the key, the cache version, and the branch. The version is a hash over the compression tool and the exact list of cached paths, so a cache saved on a macOS runner cannot restore on a Linux runner, and adding a directory to path starts a fresh entry instead of reusing the old one. Keep the PHP version in the key, as php83 does above, so an upgrade to 8.4 never restores an extension-linked vendor tree built for 8.3.

Entries expire 7 days after last use. A long-lived branch that goes quiet for a week starts cold on its next push, which is the usual explanation for a single slow run on an old pull request.

The restore-keys prefix carries most of the value in PHP. When composer.lock changes by one package, the exact key misses, the prefix restores the most recent entry on the branch, and composer install downloads and extracts only the delta. Without the prefix, a one-line dependency bump pays for a full cold install.

Cache carrying cost is small and visible on the invoice: storage bills at $0.20 per GB-month and each cache read or write at $0.0001, so keeping a 400MB Composer cache warm runs about $0.08 a month. The split restore and save variants, the container requirements, and the full input list are in the WarpBuild caching documentation.

Sizing

Runner size sets the ceiling on parallel test processes, and PHP suites usually pay for the database before they pay for the tests. These are the Linux x64 sizes worth considering, with WarpBuild rates from the pricing page and the nearest GitHub-hosted shape alongside.

Runner labelvCPURAMPrice per minuteComparable GitHub-hosted runnerGitHub list priceDifference
warp-ubuntu-latest-x64-2x28GB$0.004ubuntu-latest, 2 vCPU and 8GB on private repositories$0.00633 percent lower list price
warp-ubuntu-latest-x64-4x416GB$0.0084-core Linux larger runner$0.01233 percent lower list price
warp-ubuntu-latest-x64-8x832GB$0.0168-core Linux larger runner$0.02227 percent lower list price
warp-ubuntu-latest-x64-16x1664GB$0.03216-core Linux larger runner$0.04224 percent lower list price

Every size in the table carries 150GB of SSD storage. GitHub list prices come from the GitHub Actions billing reference and the shapes from the GitHub-hosted runner specs, both checked on 2026-08-13.

4x (4 vCPUs, 16GB). Unit-heavy suites with an in-memory or SQLite database, and feature suites that run three parallel processes or fewer. If MySQL and Redis run as service containers on the same runner, cap Paratest or Pest at three processes and leave a core for the database. Migrations and seeding are single-threaded, so the fourth core sits idle during setup regardless.

8x (8 vCPUs, 32GB). The default for a Laravel or Symfony suite with database-backed feature tests. Eight test processes each hold a PDO connection and a fixture set, MySQL gets a full core to itself during seeding, and 32GB leaves room for a per-process database. This is the size most PHP teams settle on.

16x (16 vCPUs, 64GB). Worth it when you consolidate a matrix into one job, or when a monorepo runs several packages' suites in the same run. Time your migration and seeding phase first: it runs once, serially, and no runner size shortens it.

Service containers share the runner with the job, so plan the budget together:

jobs:
  feature:
    runs-on: warp-ubuntu-latest-x64-8x
    services:
      mysql:
        image: mysql:8.4
        env:
          MYSQL_ROOT_PASSWORD: test
          MYSQL_DATABASE: app_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

Health checks matter more than the image tags. Without them, workflows fall back to a fixed sleep, which is either too short and flaky or too long and wasteful. MySQL 8 starts with a 128MB InnoDB buffer pool by default and Redis holds very little, so the pair is cheap in RAM and expensive in CPU during migration and seeding. That is the argument for 8 vCPUs rather than 16GB more memory.

To confirm a sizing choice instead of guessing, WarpBuild's CI observability reports CPU and memory metrics from the runner agent correlated with the GitHub Actions job logs, so you can see whether eight Paratest processes saturate the machine or whether six of them wait on MySQL. If you want the decision procedure on its own, the runner size answer page walks through it.

A worked cost model

Assume a feature suite that takes 14 minutes on GitHub's standard 2 vCPU hosted runner and runs 300 times a month. Assume your own measurement puts it at 8 minutes on 4 vCPUs and 5 minutes on 8 vCPUs. Substitute your real numbers, because parallel efficiency depends on how much time the suite spends waiting on the database.

SetupWall clockRate per minuteCost per runCost per month (300 runs)
GitHub-hosted, 2 vCPU14 min$0.006$0.084$25.20
GitHub-hosted 8-core larger runner5 min$0.022$0.110$33.00
warp-ubuntu-latest-x64-8x5 min$0.016$0.080$24.00
warp-ubuntu-latest-x64-4x8 min$0.008$0.064$19.20

Two results fall out of the arithmetic. Buying more cores from GitHub raises the monthly bill even when the suite finishes sooner, because the rate climbs faster than the wall clock drops. And for PHP specifically, the 4x row often wins on total cost while the 8x row wins on pull request feedback time, so the choice is a scheduling decision rather than a budget one.

Bottlenecks

Four things dominate slow PHP jobs on GitHub Actions. Each has a specific fix, and the first three are cache problems.

Cold vendor directory

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 a few hundred packages, and the extraction step alone is disk-bound. The workflow above fixes this by caching the Composer cache directory and vendor together: the archives survive a lockfile change, and the extracted tree makes a warm install close to a no-op. Add --prefer-dist so Composer takes package archives instead of cloning repositories, and avoid --no-cache, which is occasionally copied from Docker build advice where it belongs.

Autoloader dumps on every run

Composer regenerates the autoloader after every install, and on a large codebase the classmap scan is a real cost. Two settings shrink it. Set optimize-autoloader in the config block of composer.json so production-shaped classmaps are the default, and pass --classmap-authoritative in jobs that never load classes outside the map, which drops the filesystem fallback lookups entirely. If your post-autoload-dump scripts run package discovery or clear framework caches, run composer install --no-scripts and invoke the specific script you need afterwards, so a cached vendor tree stops paying for work it already did.

Extension installs through setup-php

PHP setup actions install extensions in three different ways: some ship precompiled, some come from a package repository, and the rest build through PECL inside the job. Extensions that build from source, such as imagick and some Redis or gRPC builds, are the slowest step in many PHP workflows and can dwarf the test run itself.

Three mitigations, in order of value. Ask for the smallest extension list your suite actually needs, since every entry is a potential compile. Set coverage: none unless the job publishes coverage, which skips the Xdebug install altogether, and use PCOV rather than Xdebug on the one job that does need line coverage. Cache the resolved extension directory with WarpBuilds/cache@v1, keyed on the PHP version and the exact extension list, so the compile happens once per key instead of once per job.

Database fixtures rebuilt per job

A suite that migrates from zero and seeds fixtures on every job pays the same serial cost forever. Three fixes stack. Dump the migrated schema to a SQL file, cache it against a hash of the migrations directory, and load the dump when the hash matches, so migrations run only when they change. Give each parallel process its own database created from a template, which removes the deadlocks that appear when eight processes truncate the same tables. And wrap each test in a transaction that rolls back, so seeding runs once per process rather than once per test.

When a failure reproduces only inside GitHub Actions, the WarpBuild Action Debugger pauses the workflow and opens an SSH session on the live runner, so you can inspect the database, the extension list, and the cache state on the machine that failed instead of pushing another commit with echo statements.

Proof

Start with the numbers on this page. Every WarpBuild rate above comes from the pricing page and the cloud runners documentation, and every GitHub list price is linked to GitHub's own billing reference with the date it was checked. Cost and performance claims on WarpBuild pages carry a number, a source link, and a checked-on date, and the same number appears on every surface, so you can reproduce the arithmetic in the Sizing table with a calculator.

Teams running a mixed stack can apply the same runner and cache pattern elsewhere: the Ruby on GitHub Actions page covers Bundler and RSpec, and the Docker Compose integration test page covers suites that bring up a full service graph instead of two service containers.

FAQ

Is there a WarpBuild setup-php action?

No. WarpBuild maintains drop-in forks for setup-node, setup-python, setup-go, setup-java, setup-dotnet, setup-ruby, rust-cache, setup-gradle, setup-zig, and mise-action. PHP has no fork, so keep the PHP setup action you already use and add WarpBuilds/cache@v1 for the Composer directories.

Should I cache the Composer cache directory or the vendor directory?

Cache both. The Composer cache directory holds downloaded package archives and survives a lockfile change, while vendor holds the extracted tree and makes a warm install close to a no-op. Key on composer.lock and add a restore-keys prefix so a one-package bump still restores.

What runner size fits a PHPUnit or Pest suite with MySQL and Redis?

Start at warp-ubuntu-latest-x64-8x, which gives 8 vCPUs and 32GB of RAM. Service containers run on the same virtual machine as the job, so the database and cache draw from the same budget as your test processes. A 4x runner works for suites that run three test processes or fewer.

What does it cost to run PHP builds on WarpBuild?

In the worked model above, a five-minute run on warp-ubuntu-latest-x64-8x costs $0.080, or $24.00 across 300 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.