← All posts

Tutorial CI/CD

How to Cache Composer Dependencies in GitHub Actions

Cache Composer dependencies in GitHub Actions and speed up PHP CI with lockfile-based invalidation.

Difficulty Intermediate Time 15 min
Abstract CI pipeline with Composer package downloads entering a reusable cache for faster GitHub Actions runs

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.json and composer.lock committed at the repository root.
  • GitHub Actions enabled for the repository.
  • This guide assumes an ubuntu-latest runner and the default Bash shell.
  • Your project can complete composer install in 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 install still respects composer.lock, and I avoid stale checked-out dependencies or platform-specific surprises inside vendor.

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.