How Do I Cache Composer Dependencies?

Cache the Composer files directory, keyed on composer.lock, so composer install copies archives off local disk instead of downloading them. Paths, YAML, costs.

Cache the directory that composer config cache-files-dir prints, and key the entry on a hash of composer.lock. With that entry restored, composer install --prefer-dist --no-interaction copies distribution archives off the runner disk instead of fetching one archive per package from Packagist and the git hosts behind it, which turns the install step into a local copy rather than a set of downloads.

Answer

Composer keeps several directories under its cache root, and they earn their place in a workflow for different reasons. The Composer configuration reference documents each one:

DirectoryConfig keyHoldsCache it
Files cachecache-files-dirDistribution archives downloaded per package versionYes, this is the one that matters
VCS cachecache-vcs-dirBare git clones for packages installed from sourceYes, when composer.json declares vcs repositories
Repository cachecache-repo-dirPackage metadata from Packagist and other repositoriesOptional, small
Cache rootcache-dirParent of the three aboveCache this when you want all three in one entry
vendor/noneInstalled tree plus the generated autoloaderNo

The files cache is the part that removes network work. composer install reads composer.lock, resolves nothing, and installs the exact versions the lock file names (Composer basic usage). Each of those versions is a distribution archive, and a warm files cache means every archive is already on disk, so the step unpacks instead of downloading.

Read the path from Composer rather than hardcoding it. On an Ubuntu runner it resolves under the user home cache directory, and the default differs on macOS and Windows, so a step that captures the value keeps the same workflow correct across a platform matrix.

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

jobs:
  test:
    runs-on: warp-ubuntu-latest-x64-4x
    strategy:
      fail-fast: false
      matrix:
        php: ["8.2", "8.3", "8.4"]
    steps:
      - uses: actions/checkout@v5

      - uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          extensions: mbstring, intl, pdo_mysql
          coverage: none

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

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

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

      - run: vendor/bin/phpunit --colors=always

Three flags carry the install step. --prefer-dist keeps Composer on distribution archives, which are exactly what the files cache stores, so a package that would otherwise resolve to a source checkout stays on the cached path (Composer CLI reference). --no-interaction stops any prompt from waiting on a terminal that does not exist. --no-progress keeps the log readable, since progress output on a non-interactive runner is a wall of repainted lines.

The setup-php action installs the PHP version and the extensions your suite needs, and it ships Composer, so no separate install step is required.

On WarpBuild runners the WarpBuild cache is a drop-in replacement for actions/cache@v4 and is enabled by default on Linux runners, so WarpBuilds/cache@v1 takes the same path, key, and restore-keys inputs as the workflow above. The cache is unsupported on the Windows runners, so a Windows PHP leg stays on GitHub's cache backend. The setup actions reference lists the cache-enabled setup actions WarpBuild maintains, which cover Node.js, Python, Go, Java, .NET, Ruby, Zig, Rust, Gradle, and mise. PHP is absent from that list, so a Composer workflow wires the cache action directly, as shown above.

Detail

The key, and what a partial hit buys you

composer.lock pins every package version, so a hash of that file is a precise fingerprint of the archive set the install needs. Pair the exact key with a prefix in restore-keys:

key:          ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-composer-

An exact hit means every archive is present and composer install performs no downloads. A prefix hit restores the cache written for an older lock file, which still holds every package that did not move, so Composer downloads only the versions that changed and the save step writes a fresh entry under the new key. Cache key covers how prefix fallbacks are matched and ordered.

The PHP version stays out of the key on purpose. The archives are downloads from Packagist and are byte-identical whatever PHP unpacks them, so all three legs of the matrix above share one entry. The first leg to finish writes it and the others report that the key already exists, which is the expected behavior rather than an error.

Repositories that publish a library and deliberately omit composer.lock have nothing to hash. Key those on composer.json instead and accept that the entry drifts from what the resolver picks, since the fallback there is a slower install rather than a wrong one.

Why caching the vendor directory is fragile across PHP versions

Caching vendor/ looks like the bigger win, because it appears to remove the install step rather than the downloads. Four things break.

The autoloader is generated, and the generation is per install. composer install writes vendor/composer/autoload_*.php and vendor/autoload.php at the end of the run. A restored tree carries the map from whichever run produced it, so a package added on a branch resolves against a class map that predates it.

The platform check encodes the PHP version. Composer writes vendor/composer/platform_check.php from the platform requirements resolved at install time. Restore a tree produced under PHP 8.2 into the 8.4 leg of a matrix and that leg runs a check written for a runtime it is not on, which either fails loudly or, worse, passes while the tree around it was assembled under different assumptions. Fixing this means putting the PHP version in the cache key, which turns one shared entry into one entry per leg.

Install scripts stop running. Most teams cache vendor/ so they can skip composer install, and skipping it skips the post-install-cmd and post-autoload-dump scripts declared in composer.json (Composer scripts reference). Laravel wires package discovery into post-autoload-dump, so a skipped install leaves a stale bootstrap/cache/packages.php and a newly added package that the framework never registers. Laravel builds on GitHub Actions covers the framework-specific steps around that.

The entry is the wrong shape. A vendor tree is tens of thousands of small files. The files cache is a few thousand archives that are already compressed. Packing and unpacking the first shape costs more per megabyte than the second, and it is the shape you rebuild most often, since any dependency change invalidates the whole tree.

The pattern that survives all four is the one at the top: cache the downloads, run composer install every time, and let it do the work it is designed for. When a repository genuinely needs a warm working tree rather than warm downloads, that is a different mechanism. Snapshot runners restore a whole runner disk instead of named paths, and the persistent caches guide works through when a disk snapshot beats a path cache.

Plugins, scripts, and the non-interactive rule

Composer 2.2 added the allow-plugins setting. A plugin that is not listed there prompts for confirmation on an interactive terminal and is refused without one, so a workflow that was fine on a laptop can install a partial tree on a runner. Pin the list in composer.json and keep --no-interaction on the command so the outcome is the same in both places (Composer configuration reference).

For deployment jobs, add --no-dev and --optimize-autoloader. Both change what lands in vendor/, and neither changes the files cache, since the archive set is the same and Composer decides afterwards which packages to install from it.

What the cache costs

Cache entries expire after 7 days without use, and the cache version is derived from the compression tool and the cached paths, so an entry written on a macOS runner does not restore on a Linux runner even under an identical key. Both cache line items come from the pricing page, checked on 2026-08-13:

Line itemHosted runnersBYOC
Cache storage$0.20 per GB-monthFree
Cache write, restore, or list$0.0001 per operationFree

A worked month, with the assumptions written out so you can substitute your own. A Laravel application with roughly 300 packages holds a 0.5 GB files cache. Counting the current entry plus two superseded ones still inside the 7 day window, that is 1.5 GB, which is $0.30 per month. The repository runs 500 workflow runs a month across a three-leg PHP matrix, so around 1,600 restore and save operations, which is $0.16 per month. The runner minutes dominate: warp-ubuntu-latest-x64-4x bills $0.008 per minute, so 500 runs of three 4 minute legs is 6,000 minutes and $48.00. The cache line items are $0.46 of a $48.46 bill.

Sizing guidance for PHP test suites is in PHP builds on GitHub Actions.

GitHub's own cache backend works differently: it caps the combined size of all caches in a repository at 10 GB by default and evicts least recently used entries once the total passes it (GitHub caching documentation, checked on 2026-08-13). That ceiling arrives early in a monorepo holding Composer, npm, and Docker entries at once, and a 0.5 GB Composer entry is often the one evicted.

Which Composer directory should I cache?

Cache the path that composer config cache-files-dir prints, which holds the downloaded distribution archives. Read it in a step and pass it to the cache action rather than hardcoding it, because the default differs across Linux, macOS, and Windows. Add the cache-vcs-dir path when composer.json installs packages from git repositories, since those clones are fetched over the network too. The inputs for both paths are in the WarpBuild cache documentation.

Should I cache the vendor directory instead?

No. The tree carries a generated autoloader and a platform_check.php written for the PHP version that produced it, so restoring it into another matrix leg hands that leg files built for a different runtime. Caching vendor/ to skip composer install also skips the post-install-cmd and post-autoload-dump scripts that frameworks rely on, which is how a Laravel repository ends up with a package the container never registers. Laravel builds on GitHub Actions has the framework-specific workflow.

Do I need a separate cache key per PHP version?

No for the files cache. The archives are Packagist downloads and are identical whatever PHP version unpacks them, so one key built from runner.os and a hash of composer.lock serves every leg of a matrix, and all legs read the same entry. A vendor/ cache does need the PHP version in the key, which multiplies storage by the number of legs and is one more reason to cache the downloads. Cache key covers what belongs in a key and what belongs in restore-keys.

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.