Every storage migration plan starts the same way: pick a weekend, tell the team, run aws s3 sync between the old bucket and the new one, and hope nothing writes to the old bucket while the sync is running. It usually takes longer than anyone estimated, because the sync doesn't know which files matter. It copies every orphaned export and abandoned avatar right alongside the ones people actually open, and someone is paying egress on all of it.

The other option is worse in a quieter way. A developer adds a fallback check, Storage::disk('old') when Storage::disk('new') comes up empty, scattered through every read path that touches a file. It works. It also never gets removed, because removing it means proving nothing still depends on the old disk, and nobody wants to be the one who's wrong about that.

Laravel 13.26, released August 18, replaces both of those with a filesystem driver that does the two-disk dance inside the disk itself.

What a Read-Through Disk Actually Does

The new read-through driver composes two existing disks, a primary and a fallback, into one disk your application code never has to think about:

'assets' => [
    'driver' => 'read-through',
    'primary' => 'r2',
    'fallback' => 'legacy-s3',
],

Reads check the primary first. If the file isn't there yet, the driver falls back to the old disk, serves the content, and copies the file to the primary on the way through. The next read comes straight from the primary. Nobody scheduled that promotion. It happened because someone asked for the file.

Writes, deletes, and directory listings only ever touch the primary, which is the part worth sitting with before you turn this on. Laravel News's deep dive is direct about the sharp edges: a directory listing shows only what's been promoted or freshly written, so anything that iterates a folder to find files won't see what's still sitting untouched on the fallback. Existence and metadata checks, exists(), size(), lastModified(), consult whichever disk actually holds the file, but they don't trigger a copy on their own. If your migration depends on eventually walking every file, the read-through driver alone won't get you there. It gets you the 95% of files people actually request, for free, while you plan something deliberate for the rest.

There's also a copy => false option that serves from the fallback without promoting anything, which is exactly the shape you want for a development environment pointed at production data: read the real files, never write to the real bucket. And promotion failures are swallowed by default, so a read never breaks because the background copy failed; setting throw_on_promotion_failure => true flips that if you'd rather know immediately.

The feature contributed by Taylor Otwell landed in PR #61140, with the no-copy option added by a second contributor days later. The same release shipped Queue::forward(), which does the equivalent trick for queue names instead of files: declare in one place that anything dispatched to reports should land on reports.fifo on a different connection, without touching the job classes or dispatch sites that still say reports. Different subsystem, same instinct: stop making every caller aware that something moved.

The Bulk Sync Was Never the Real Cost

The expensive part of a storage migration was never the transfer. It was the certainty problem. You can't safely decommission the old bucket until you're sure nothing depends on it anymore, and a bulk sync doesn't tell you that. It just moves everything and leaves you exactly as uncertain as before, minus a chunk of egress budget.

A read-through disk answers a narrower, more useful question: what's actually being used. Every promoted file is proof of demand. After a few weeks, whatever hasn't moved is either genuinely cold or genuinely gone, and that's a much smaller, much more confident decision to make about the fallback disk than "did the sync catch everything."

We've written before about building integrations you can walk away from, and the underlying idea is the same one at work here: put a seam between your code and the thing that might change under it, so the swap doesn't require touching every caller. A read-through disk is that seam, purpose-built for storage. If you're just getting your Laravel app onto S3-compatible storage for the first time, our earlier guide still covers the basics of getting Storage::disk() configured correctly; this is what you reach for once you're moving off one bucket onto another.

The Pattern Outlives the File System

None of this is really about files. It's about what happens when a system you built five years ago needs to point somewhere new, and a hard cutover date is the only plan anyone's proposed. The CRM migration where sales still has open deals in the old system on go-live day. The payment processor swap where refunds on old transactions still need the old API for another year. The legacy database where half the reporting queries assume a table that's about to move.

The lazy-migration shape works in all of those, not because the code is identical but because the question is the same: can the system serve a request correctly regardless of which side of the migration the data currently sits on, while quietly moving the answer closer as real usage reveals what actually matters. That's a design decision, not a framework feature, and it's the kind of thing worth working out before you're three weeks from a deadline with a bulk sync running in the background and no way to know if it caught everything.

When we scope custom development work that touches a system swap, this is one of the first questions we ask: does this have to be a weekend, or can it be a few quiet weeks where nothing breaks and nothing changes for the people using it. Laravel just made the file storage version of that question a lot cheaper to say yes to. Most of the interesting migrations in a growing business aren't about files, but they follow the same shape once you're looking for it.