Every push, your Laravel CI checks out the repo, installs PHP, then spends a minute watching Composer re-download the exact same packages it fetched last time. npm ci does the same for your asset build. Nothing changed — the lockfiles are byte-for-byte identical — but the runner boots from an empty disk on every run.
The fix is a few lines of actions/cache@v4. Cache Composer's download directory and the npm cache in GitHub Actions, key both on your lockfiles, and composer install and npm ci restore from disk instead of hitting the network. Here's the exact setup, the right cache key, and the vendor-versus-cache-directory decision that trips people up.
Cache Composer's global cache directory#
Composer keeps two separate things on disk: your project's vendor directory, and a global download cache of the package archives it has already fetched. The download cache is the one worth persisting between CI runs. Restore it and composer install pulls each package from local disk instead of the network — while still running a full, correct install every time.
That last part is why I cache the download directory rather than vendor itself. Cache vendor and you're trusting that a tree resolved on one run is valid on the next; skip the install to "save time" and you risk a stale autoloader or a dependency resolved for the wrong PHP version. Cache the download directory and composer install stays the source of truth — you're only skipping the slow part, the downloads.
Composer won't tell you where that directory lives unless you ask, and the path differs between runners, so never hard-code it:
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
That writes the path — usually something like /home/runner/.cache/composer/files — to a step output you'll point the cache at next. And it's not only your test job that gains: any job running composer install benefits, including a static-analysis pass that runs Duster, Pint, PHPStan, and Rector in one command.
Key the Composer cache on composer.lock and the PHP version#
A cache entry is only as useful as its key. actions/cache does an exact-match lookup on key; if that misses, it walks restore-keys as ordered prefixes for a partial hit. Hash composer.lock into the key so the cache invalidates the instant a dependency changes, and prefix it with the OS and PHP version so a job on 8.3 never restores packages Composer resolved for 8.4.
Here's the whole Composer half of a tests.yml, matrix included so the PHP version in the key actually earns its place:
# .github/workflows/tests.yml
name: tests
on:
push:
branches: [production, develop]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.3', '8.4']
steps:
- uses: actions/checkout@v4
- name: Setup PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
tools: composer:v2
coverage: none
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Cache Composer packages
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: |
${{ runner.os }}-php-${{ matrix.php }}-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
The key reads left to right as OS → PHP version → a composer- namespace → the lockfile hash. When composer.lock changes the exact key misses, but restore-keys still restores the previous cache for that OS-and-PHP pairing, so composer install only downloads the packages that actually moved. Not running a matrix? Drop matrix.php and hard-code the version — but the moment you test against more than one, this is what stops their caches colliding. The same axis drives full matrix testing across PHP and database versions.
Order matters: the cache step sits before composer install, because it has to restore the download directory before the install can read from it. On a hit, install finishes in a handful of seconds — it's copying archives off local disk, not fetching them over HTTPS.
Cache the frontend build with the npm lockfile#
Most Laravel apps ship a Vite build, and npm has the identical problem — a cold ~/.npm means every run re-downloads the same tree. The cleanest fix is to let actions/setup-node handle it: point its built-in cache at npm and it wires up actions/cache under the hood.
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Build assets
run: |
npm ci
npm run build
cache: 'npm' caches npm's global download cache — ~/.npm, keyed on package-lock.json automatically — not node_modules. npm ci still runs and rebuilds node_modules deterministically from the lockfile; it just installs from the local cache instead of the registry.
If you're not using setup-node — a Node preinstalled in a custom runner image, say — cache ~/.npm yourself with the same pattern as Composer:
- name: Cache npm downloads
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
Resist the urge to cache node_modules directly. Same reasoning as vendor: npm ci reproduces it from the lockfile, and a restored node_modules can smuggle in platform-specific binaries that don't match the runner.
Restore before install and verify the speedup#
With the steps in place, prove the cache is doing something rather than assuming it. Open the job log and expand the cache step. A hit reads like this:
Cache restored successfully
Cache restored from key: Linux-php-8.4-composer-9b2f1a7c3e...
A miss — which every first run is, because the entry doesn't exist yet — reads:
Cache not found for input keys: Linux-php-8.4-composer-9b2f1a7c3e..., Linux-php-8.4-composer-
On a miss the job downloads normally and saves a fresh entry at the end (Cache saved with key: ...). The payoff starts on run two. If you're still missing after that, the usual culprit is branch scoping: a cache saved on a feature branch is visible only to that branch and to the default branch — it won't restore on an unrelated branch until the entry exists on production/main. Entries untouched for seven days are also evicted.
The shape of the win, on a typical mid-sized app:
| Step | Cold (no cache) | Warm (cache hit) |
|---|---|---|
composer install |
~45s | ~6s |
npm ci |
~25s | ~5s |
Numbers scale with dependency count, but the pattern holds: the network round-trips disappear and install becomes a local copy. Across a two-version PHP matrix that's roughly a minute saved on every job, every push. Once caching has taken install time off the table and the tests themselves are the bottleneck, the next lever is sharding the Pest suite across parallel runners.
Wrapping Up#
Cache the Composer download directory (not vendor), key it on composer.lock plus the OS and PHP version, put the step before composer install, and do the npm equivalent with setup-node's built-in cache. That's the whole pattern — a few lines that pay for themselves by run two and keep paying on every push.
With a fast, green pipeline, the natural next step is making it deploy: wire the same workflow into a Kamal 2 deployment to your own VPS, or weigh the managed options in my Forge versus Vapor breakdown for 2026.
FAQ#
How do I cache Composer dependencies in GitHub Actions?
Add an actions/cache@v4 step that stores Composer's download directory. Resolve the path with composer config cache-files-dir into a step output, point the cache path at it, and key it on hashFiles('**/composer.lock'). Place the step before composer install so the packages are on disk when the install runs — on a cache hit, install pulls from local disk instead of the network.
What should I use as the Composer cache key?
Combine three things: runner.os, the PHP version, and a hash of the lockfile — for example ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }}. The lockfile hash invalidates the cache whenever dependencies change, while the OS and PHP version stop jobs in a matrix from restoring each other's incompatible packages. Add a matching restore-keys prefix so a changed lockfile still gets a partial restore.
Should I cache the vendor folder or Composer's cache directory?
Prefer Composer's download directory (composer config cache-files-dir). Caching it keeps composer install as the source of truth — install always runs and produces a correct, platform-appropriate vendor and autoloader, and you only skip the slow network downloads. Caching vendor directly can be marginally faster if you also skip install, but it risks a stale autoloader or a tree resolved for a different PHP version. The download-directory approach is the more robust default.
Why isn't my GitHub Actions cache being restored?
The most common reasons are branch scoping and a changed key. A cache created on one branch is only available to that same branch and to the repository's default branch, so an unrelated feature branch won't see it until the entry exists on the default branch. Beyond that: the lockfile changed and there's no restore-keys fallback, the entry was evicted after seven days idle, or the cached path doesn't match between the save and restore runs. The first run is always a miss by design.
How much does caching actually speed up a Laravel CI run?
For a typical Laravel app it takes composer install from roughly 40–60 seconds of downloading down to under 10 on a hit, and npm ci from around 25 seconds to about 5. It won't shrink the fixed costs — checkout, booting PHP, running migrations and the tests themselves — so the total saving is usually 30–60 seconds per job. Multiply that across a matrix and every push, though, and it adds up fast, for the cost of about eight lines of YAML.