I use a small cache layer in GitHub Actions to stop Composer from downloading the same packages on every run. By the end, your workflow will restore cached Composer downloads, run a normal composer install, and automatically refresh that cache when composer.lock changes.
Prerequisites
- A GitHub repository with a PHP project.
composer.jsonandcomposer.lockcommitted at the repository root.- GitHub Actions enabled for the repository.
- This guide assumes an
ubuntu-latestrunner and the default Bash shell. - Your project can complete
composer installin CI without interactive prompts.
Step 1 — Create the CI workflow
I start with a minimal workflow that checks out the code and installs PHP. If your repo already has a PHP workflow, edit that file in place instead of creating a second job just for Composer caching.
mkdir -p .github/workflowscat > .github/workflows/ci.yml <<'YAML' name: CI
on:
push:
pull_request:
jobs:
composer:
runs-on: ubuntu-latest
text
steps:
- name: Check out the code
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
YAML
This gives me a clean starting point. I keep the PHP version explicit because I want the cache key to reflect the runtime the job uses.
Step 2 — Resolve the Composer cache directory at runtime
Next, I ask Composer where its download cache lives instead of hardcoding a path. That keeps the workflow portable and avoids baking in assumptions like ~/.composer/cache/files, which can vary by environment and Composer version.
- name: Get Composer cache directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
Note: I cache Composer's download directory, not
vendor. That is the safer path for CI: the cache stays reusable across runs,composer installstill respectscomposer.lock, and I avoid stale checked-out dependencies or platform-specific surprises insidevendor.
The id: composer-cache line matters because the next step reads the directory from ${{ steps.composer-cache.outputs.dir }}.
Step 3 — Restore the Composer dependencies cache
Now I add the cache step before composer install. The key includes the runner OS, the PHP version, and a hash of composer.lock. When the lockfile changes, GitHub Actions creates a new cache automatically. The restore-keys prefix lets GitHub fall back to an older cache for the same OS and PHP version, which still helps because Composer only needs to fetch what is missing.
Replace the workflow with this complete file:
cat > .github/workflows/ci.yml <<'YAML' name: CI
on:
push:
pull_request:
jobs:
composer:
runs-on: ubuntu-latest
text
steps:
- name: Check out the code
uses: actions/checkout@v4
- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none
- name: Get Composer cache directory
id: composer-cache
run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
- name: Cache Composer downloads
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-php-8.3-composer-${{ hashFiles('composer.lock') }}
restore-keys: |
${{ runner.os }}-php-8.3-composer-
- name: Install dependencies
run: composer install --prefer-dist --no-interaction --no-progress
YAML
This is the entire working setup. The order is important: checkout first, then PHP, then read the Composer cache directory, then restore the cache, and only after that run composer install.
Step 4 — Push the workflow and verify cache hits
Commit the workflow and push it so GitHub Actions can run it. The first run is expected to be a cache miss because nothing has been saved yet. After that run finishes, trigger a second run to confirm the cache is being restored.
git add .github/workflows/ci.yml git commit -m "Cache Composer downloads in GitHub Actions" git push
After the first run completes, trigger one more run:
git commit --allow-empty -m "Trigger CI to verify Composer cache" git push
Open the second run in the Actions tab and inspect the Cache Composer downloads step. On the first run, you should see a cache miss. On the second run, you should see a restore message for the key you configured. The install step should also spend less time downloading packages because Composer can reuse the cached archives.
If you later update dependencies and commit a new composer.lock, the hash changes and the workflow will build a fresh cache automatically. That gives you clean invalidation without any manual cache busting.
Wrap-up
That is the safest way I cache Composer dependencies in GitHub Actions: cache Composer's own download directory, keep the key tied to composer.lock, and let composer install do the final resolution. It speeds up repeated PHP CI runs without the brittleness of caching vendor, and it stays accurate when your dependencies change.