Skip to content
Back to blog
LaravelDevOpsSeguridad

Control who writes the cache in GitHub Actions

cache-mode lets Laravel pipelines limit which jobs can restore or save dependency caches.

Ismael Catala4 min read

The real issue is granting too much access

In many Laravel pipelines, every job gets the same ability to restore and save caches. That is convenient until the workflow runs code I do not consider trustworthy, such as an external contribution. A test job may need to speed up composer install or npm ci, but it does not automatically need permission to publish a cache that a later job will reuse.

GitHub Actions now provides cache-mode to separate those responsibilities. I can set it for the entire workflow or on a specific job, with the job-level value taking precedence over the workflow-level setting. Cache access is enforced through scoped cache tokens, so this is more than relying on an action to behave as intended.

Four modes for a straightforward policy

read allows cache restores but prevents saves. write allows both operations, while write-only blocks restores and permits saves. Finally, none blocks both cache restores and cache saves.

The useful part is not memorising the four values, but assigning them to the actual purpose of each job. Tests and static analysis usually fit read, while a job that refreshes a cache from a trusted branch can use write. I would use write-only when I want to warm a new cache without consuming an existing one, and none for jobs where caching adds no value or should not be available.

A Laravel pattern for npm and Composer

My default approach would be to make the workflow read-only and grant write access only to a job that runs after a push to main. That lets pull request jobs benefit from existing caches without being able to alter them. The example uses npm caching, but the same permission split applies when Composer caches are restored in other steps.

name: Laravel Tests
 
on:
  pull_request:
  push:
    branches:
      - main
 
cache-mode: read
 
jobs:
  tests:
    runs-on: ubuntu-latest
 
    steps:
      - uses: actions/checkout@v6
 
      - name: Restore npm cache
        uses: actions/cache@v4
        with:
          path: ~/.npm
          key: npm-${{ hashFiles('**/package-lock.json') }}
 
      - name: Install dependencies and run tests
        run: |
          composer install
          npm ci
          php artisan test
 
  refresh-cache:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    cache-mode: write
 
    steps:
      - uses: actions/checkout@v6
 
      - name: Prepare dependencies
        run: |
          composer install
          npm ci
 
  deploy:
    runs-on: ubuntu-latest
    cache-mode: none
 
    steps:
      - name: Deploy
        run: echo "This deployment does not use the Actions cache"

There is no need to turn every job into a write-capable job for the pipeline to keep working. A job that is not allowed to restore treats that operation as a cache miss, while a job that cannot save simply does not create an entry. GitHub documents that these restrictions do not fail the job or the workflow by themselves, which is worth remembering when reading workflow logs.

The fine print matters

cache-mode does not fix a poorly designed cache key. If a cache combines downloaded dependencies, generated assets, and build outputs without a key that represents that content properly, consistency problems remain even when write access is restricted. I also need to be deliberate about the paths I cache: downloaded dependencies are not the same thing as a full vendor directory or build output that may later be executed.

I would not add write or write-only to low-trust triggers just because it is convenient. GitHub explicitly warns that doing so can bypass the secure read-only restriction and bring back cache-poisoning risk. An explicit setting is powerful because it changes the default behaviour, so it should be treated as a security permission rather than a performance switch.

Self-hosted runners still benefit from this boundary

On my homelab, a self-hosted runner does not automatically make every job trustworthy. If that runner processes workflows from repositories or branches with different trust levels, separating cache permissions reduces an attack surface that is often overlooked in the name of faster builds. It is a small control, but it follows the useful rule that each job should receive only the access it needs.

It is also a good reason to make a Laravel workflow easier to understand. A dedicated dependency-preparation job running from main can be the only job that writes caches, while PHP tests, frontend checks, static analysis, and pull request validation jobs stay read-only. The pipeline does not become more complicated; the intent of every job is simply clearer in the YAML.


Source: GitHub Changelog

Official cache-mode documentation