Control who writes the cache in GitHub Actions
cache-mode lets Laravel pipelines limit which jobs can restore or save dependency caches.
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.