Storage Migrations Without the Bulk Sync: Laravel 13.26's Read-Through Filesystem.
August 30, 2026
10 minutes
Storage migrations are one of those jobs that sounds straightforward until you actually have to do one.
You've got an application running on S3. It works. Then free egress starts sounding very appealing - Cloudflare R2 has been making a lot of noise - and you decide it's time to move. So far, so simple.
Then reality hits. You've got several million objects in that bucket. A bulk aws s3 sync between providers takes days, costs real money in egress fees, and copies every orphaned export and stale avatar from 2019 alongside the files your users are actually opening right now. You're shifting the entire bucket regardless of whether any of it is worth keeping.
The code-side alternative isn't much better. You scatter Storage::disk('old') fallbacks through every read path in your application, write some conditional logic to try the new disk first, and promise yourself you'll clean it up once the migration is done. Except it never gets cleaned up. It just becomes part of the codebase, and whoever touches it next has to figure out what on earth is going on.
There's never really been a clean answer to this problem. Until now.
Laravel 13.26 shipped a read-through filesystem driver from Taylor Otwell that is, genuinely, the right solution to this. Let me show you how it works.
The Core Idea: Let Traffic Do the Migration
The read-through driver borrows from a caching pattern most developers will already recognise. Rather than migrating all your files upfront before you switch, you let your application traffic do the migration for you - gradually, lazily, without any downtime or bulk operations.
You configure a read-through disk that sits in front of two real disks: a primary (where you want to end up) and a fallback (where everything currently lives). When your application asks for a file, this is what happens under the hood:
Laravel checks the primary disk first
If the file isn't there, it checks the fallback
It copies the file from fallback to primary - the driver calls this a promotion
Returns the file to the caller as normal
After that first request, subsequent reads go straight to primary. No second check, no fallback, done. The hot files — the ones your users actually touch — migrate themselves on first access. Cold objects, the stale exports and forgotten uploads nobody's opened in years, stay in the fallback until you're ready to deal with them separately.
Your application code never changes. It just talks to Storage:: as it always has.
Setting It Up
Configuration lives entirely in config/filesystems.php. You register your two underlying disks as normal, then add a third read-through disk that references them by name:
1<?php 2 3'disks' => [ 4 5 'r2' => [ 6 'driver' => 's3', 7 'key' => env('CLOUDFLARE_R2_KEY'), 8 'secret' => env('CLOUDFLARE_R2_SECRET'), 9 'bucket' => env('CLOUDFLARE_R2_BUCKET'),10 'endpoint' => env('CLOUDFLARE_R2_ENDPOINT'),11 ],12 13 'legacy-s3' => [14 'driver' => 's3',15 'key' => env('AWS_ACCESS_KEY_ID'),16 'secret' => env('AWS_SECRET_ACCESS_KEY'),17 'bucket' => env('AWS_BUCKET'),18 'region' => env('AWS_DEFAULT_REGION'),19 ],20 21 'assets' => [22 'driver' => 'read-through',23 'primary' => 'r2',24 'fallback' => 'legacy-s3',25 ],26 27],
Then update your FILESYSTEM_DISK env variable to assets, and you're done. Controllers, jobs, and services all continue using Storage::disk('assets') - or just Storage:: if it's your default - without knowing two buckets exist behind the scenes.
What I like about this is how little changes in your application. There's no dual-disk logic to thread through your codebase. The abstraction holds. To the rest of your application, assets is just a disk.
A couple of extra details worth knowing: the driver validates the pair at resolution time, so giving it the same disk on both sides or a missing disk name throws an InvalidArgumentException immediately rather than letting it fail mysteriously at runtime. And both primary and fallback also accept inline disk configuration arrays rather than disk names, which is handy if you don't want to register the underlying disks separately.
What Goes Where
The driver makes deliberate routing decisions for each type of filesystem operation. These are worth internalising before you go live.
Reads (get(), readStream()) - Check primary first. On a miss, fall back and promote. Streamed reads buffer through php://temp rather than loading the whole object into a PHP string, which matters considerably when files are large.
Writes (put(), writeStream(), visibility changes) - Primary only. New uploads land in R2 from the moment you flip the driver on. The fallback never receives anything new.
Existence checks and metadata (exists(), size(), mimeType(), lastModified()) - Consults whichever disk currently holds the file, without triggering a promotion.
Deletes - Fallback first, then primary. Removes the path from both stores.
Directory listings - Primary only.
URL and temporary URL generation - Resolves against whichever disk currently contains the file, so CDN URLs continue to point at the right place regardless of where a file is in the migration process.
The Sharp Edges
The driver is genuinely clever, but there are a few gotchas worth knowing before you flip this on in production.
Directory Listings Only Show Primary
Storage::files('avatars') will only return what's been promoted or written since you switched. Anything still sitting on the fallback disk won't appear. During a migration that's usually intentional - you don't want to list both sides - but it's a surprise if you're relying on directory iteration for anything.
If you need to enumerate the full set of files including unpromoted objects, list the fallback directly:
1<?php2 3Storage::disk('legacy-s3')->allFiles('avatars');
This is how you'd drive a background backfill job too - list the fallback, check what's already on primary, copy the rest.
The Ghost Delete Problem
Deletes remove the path from both disks, which is the right behaviour. But there's a subtlety: if your fallback credential is read-only - which is a perfectly sensible security posture, why would your application need delete access to the old bucket? - the fallback delete will fail, and the primary delete won't run.
The driver's throw option controls whether that failure surfaces to your application. The short version: if you're holding the source bucket immutable until cutover, you can't safely delete through the read-through disk during the migration window. You'd need either full credentials on the fallback, a tombstone mechanism, or to defer all destructive operations until after the fallback is retired.
Worth thinking through before you go live.
Mind the Memory on Large Files
get() loads the entire object into a PHP string before promoting it to primary. For a 50KB avatar, that's nothing. For a 500MB export file, that's a problem. Use readStream() for anything sizeable:
1<?php2 3// Avoid for large files during promotion4$contents = Storage::disk('assets')->get('exports/large-report.csv');5 6// Prefer this — buffers through php://temp7$stream = Storage::disk('assets')->readStream('exports/large-report.csv');
readStream() keeps PHP memory usage manageable, though you'll still need enough temporary disk space for the object on the server, and the first response includes the combined time to download from fallback and upload to primary. For very large objects, a background bulk migration pass before they enter the request path is the cleaner option.
Promotion Is Best-Effort by Default
If copying to primary fails - network blip, permissions issue, whatever - the driver still returns the file from fallback. The promotion exception is swallowed. For most situations that's the right call: a failed copy to the new bucket shouldn't take your downloads offline.
If you'd rather know immediately when a promotion fails, there's a config option for that:
1<?php2 3'assets' => [4 'driver' => 'read-through',5 'primary' => 'r2',6 'fallback' => 'legacy-s3',7 'throw_on_promotion_failure' => true,8],
You'll also need the disk's standard throw option set to true for application code to actually receive the exception - without it, get() catches it internally and returns null.
Reading Without Copying - Brilliant for Local Dev
This came in a separate pull request from Josh Butts (@jimbojsb), and it's a lovely addition. A copy option lets you get the layered read behaviour without the promotion:
1<?php2 3'assets' => [4 'driver' => 'read-through',5 'primary' => 'local-assets',6 'fallback' => 'production-s3',7 'copy' => false,8],
With copy set to false, fallback hits are served directly and nothing gets promoted. The obvious use case is local development with a production database snapshot: your local DB has rows referencing files that only exist in the production bucket, and this config lets those images render locally without slowly mirroring the whole production bucket onto your laptop.
It's also a sensible first phase for a cautious migration - cut reads over to the new layout, watch error rates, and only enable promotion once you're confident everything is healthy.
A Migration Playbook
Putting it all together, here's the pattern for moving from a legacy S3 bucket to R2:
Step 1 - Set up the destination. Create the new R2 bucket and register its disk config in config/filesystems.php alongside the existing legacy-s3 disk. No traffic touches R2 yet.
Step 2 - Introduce the read-through disk. Set R2 as primary, legacy S3 as fallback. Update FILESYSTEM_DISK to point at the read-through disk. From this point, new uploads land in R2 immediately, and every requested file promotes itself on first access.
Step 3 - Backfill the cold tail. After enough time has passed for your active working set to migrate through normal traffic, write a batched background job to enumerate the fallback and copy anything not yet present on primary:
1<?php 2 3// Rough example of a backfill job 4$fallbackFiles = Storage::disk('legacy-s3')->allFiles(); 5 6foreach ($fallbackFiles as $path) { 7 if (Storage::disk('r2')->exists($path)) { 8 continue; // already promoted, skip it 9 }10 11 Storage::disk('r2')->writeStream(12 $path,13 Storage::disk('legacy-s3')->readStream($path)14 );15}
For anything large, batch this into queued jobs rather than running it in a single pass.
Step 4 - Cut over and clean up. Once the background sync is done and R2 key counts check out, swap the read-through config for a plain S3 disk pointing at R2. Retire the legacy bucket per your data retention policy.
What makes this approach clean is that rolling back between steps 2 and 4 is a single config change. The legacy bucket is never modified - only read from - so it remains complete and accurate throughout. If something goes wrong at any point, reverting is trivial.
Our Take
We haven't had the chance to run this in production yet although we're about to implement this on our own website rebuild as we move it from AWS to Cloud and S3 to R2, so at the moment we can't give you hard numbers on real-world promotion performance or the egress cost impact of that initial S3-to-R2 read. What we can say is that this is the right approach to a problem that's had no good answer for a long time.
The previous options were all a bit rubbish in their own way. Bulk syncs are wasteful and don't solve the zero-downtime requirement. Custom dual-disk wrappers scattered through application code are a maintenance headache. Infrastructure-only tools like Cloudflare Sippy and Super Slurper are great but don't live inside your application's storage abstraction. The read-through driver does, which means it composes cleanly with everything else in your Laravel stack and doesn't require touching application code at all during the migration.
The sharp edges are real but manageable. The ghost-delete issue only bites if you're holding the source bucket read-only, which you might be, and it's easy to plan around. The directory listing gap only matters if you're iterating directories for business logic during the transition window. The memory consideration is a one-line fix with readStream().
If you've got a storage migration ahead of you - S3 to R2, one bucket to another, local to object storage, or anything the Flysystem adapters support - this is now the first thing I'd reach for.
Taylor Otwell shipped the core driver in PR #61140 and Josh Butts added the copy option in PR #61155. Aaron Francis has written a thorough deep-dive on the official Laravel blog covering the full filesystem contract and egress cost breakdown - worth reading alongside the docs if you want the complete picture before you go live.