Laravel Read-Through Filesystem: Lazy Storage Migration
Moving millions of files between storage buckets is expensive and slow. A bulk aws s3 sync copies every stale file, costs real money in egress, and takes days. Scattering Storage::disk('old') fallbacks through your codebase is the other common approach — and it never quite gets cleaned up.
Laravel 13.26 solves this with a first-class read-through filesystem driver. You compose it from a primary disk and a fallback disk, and the driver handles the two-disk dance internally. Application code never knows two buckets exist.
Configuring a Read-Through Disk
Add the driver to config/filesystems.php and reference your existing disks by name:
'disks' => [
'r2' => [
'driver' => 's3',
// Cloudflare R2 credentials...
],
'legacy-s3' => [
'driver' => 's3',
// the bucket you are leaving...
],
'assets' => [
'driver' => 'read-through',
'primary' => 'r2',
'fallback' => 'legacy-s3',
],
],
Controllers and jobs call Storage::disk('assets') as normal. Both primary and fallback also accept an inline config array if you prefer not to register the underlying disks separately. The manager validates the pair at resolution time — a missing side, duplicate disks, or a self-referencing disk throws an InvalidArgumentException immediately.
How Reads, Writes, and Deletes Are Routed
Understanding the routing rules before going to production matters:
- Reads (
get(),readStream()) check the primary first, then the fallback. A fallback hit copies the file to the primary and returns the contents. Streamed reads buffer throughphp://tempto avoid loading large files into memory. - Writes, deletes, moves, and copies target the primary only.
- Directory listings (
files()) reflect the primary only — unpromoted fallback content is invisible to directory iteration. - Existence checks and metadata (
exists(),size(),mimeType(),lastModified(),url(),temporaryUrl()) query whichever disk holds the file without triggering a copy.
Sharp Edges to Know
Two behaviours can surprise you. First, files() only lists primary content, so anything that iterates a directory to discover files will miss unpromoted objects. Second, delete() only removes the file from the primary. If the file still exists on the fallback, the next read will resurrect it. During a migration this is usually fine — the fallback is going away — but it is worth knowing.
Promotion is best-effort by default: if copying to the primary fails, the read still succeeds from the fallback and the exception is swallowed. Set 'throw_on_promotion_failure' => true to surface those errors immediately.
Reading Without Promoting
Sometimes you want the layered reads without the automatic migration. Pass 'copy' => false to disable promotion:
'assets' => [
'driver' => 'read-through',
'primary' => 'local-assets',
'fallback' => 'production-s3',
'copy' => false,
],
This is ideal for local development seeded from a production database snapshot: file references in the database resolve against the production bucket without slowly mirroring gigabytes onto your laptop. It also works as a cautious first phase of a real migration — cut reads over and watch error rates before allowing promotion to write to the new bucket.
A Four-Step Migration Playbook
- Create the new bucket and add its disk config alongside the old one.
- Repoint your existing disk name at a read-through pair (new bucket primary, old bucket fallback). New uploads land in the new bucket immediately; every requested file promotes itself on first read.
- After normal access patterns have cycled, backfill the remaining long tail with a one-off sync or a batched background job — you only move what nobody asked for.
- Swap the read-through config for a plain disk pointing at the new bucket and decommission the old one.
At no point does a single deploy flip all traffic at once, and rolling back is a config change because the old bucket remains complete throughout.
Key Takeaways
- The
read-throughdriver shipped in Laravel 13.26 (PR #61140). - Hot files migrate themselves on first access; cold files stay put until you decide.
- Writes, deletes, and directory listings always target the primary only.
- Set
throw_on_promotion_failure => trueto surface copy failures instead of swallowing them. - Use
copy => falsefor read-only layering in development or cautious migration phases. - Rolling back is a config change — the fallback bucket stays intact throughout.
Source: Laravel Read-Through Filesystem: Lazy Storage Migration — Laravel News, August 19, 2026.