copyToDisk and moveToDisk - Disk-to-Disk Without the Stream Boilerplate.
September 13, 2026
3 minutes
Here's a scenario for you, and I bet it's one you've come across before.
You're generating pdf reports on the local disk or a temp disk on the server. Once it's finished you actually need it to be on your S3 disk. Same path of course but different location all together.
In the past you might have written something similar to this
1<?php 2 3$stream = Storage::disk('local')->readStream('reports/report.csv'); 4 5Storage::disk('s3')->writeStream('reports/report.csv', $stream); 6 7if (is_resource($stream)) { 8 fclose($stream); 9}10 11Storage::disk('local')->delete('reports/report.csv');
Look familiar? This works of course until it doesn't lets say you forget the fclose and you start leaking resource depending on the adapter you're using. Copy to the wrong path and you're trying to find the file in the wrong location. And of course the big one code duplication, if you keep writing this all over the place there is a lot of duplicated code.
So when a recent PR when it PR #61511 by Jack Bayliss (How many PRs is that now?) landed It was one of those PRs that really adds a lovely bit of DX to the framework.
The new look
Copy to another disk and keep the original :
1<?php2 3Storage::disk('local')->copyToDisk('s3', 'reports/report.csv');
Move it (copy, then delete from the source):
1<?php2 3Storage::disk('local')->moveToDisk('backup', 'reports/report.csv');
Do you need to send it to a different destination path then use the third argument:
1<?pph2 3Storage::disk('local')->copyToDisk(4's3',5'reports/report.csv',6'2026/09/report.csv',7);
Signatures are straightforward:
copyToDisk(string|Filesystem $disk, string $from, ?string $to = null): boolmoveToDisk(string|Filesystem $disk, string $from, ?string $to = null): bool
Under the hood it's still streaming - readStream on the source, writeStream on the destination, fclose in a finally so you don't have to remember it - but you get to write the intent instead of the plumbing.
moveToDisk is literally copy-then-delete on the source path, which is exactly what most of us were doing by hand anyway.
And then it got a little bit nicer
Same day, almost: PR #61519 from ziadoz lets the first argument be a disk instance, not only a name.
1<?php2 3$local = Storage::disk('local');4$archive = Storage::disk('s3');5$local->copyToDisk($archive, 'reports/report.csv');6$local->moveToDisk($archive, 'reports/report.csv', '2026/report.csv');
That sounds like a tiny follow I know, that is until you've got disks resolved dynamically - from config, from a tenant, from a feature flag. Passing the instance you've already got means one less trip through the manager and code that reads the way you think about the problem: from this disk, to that disk.
Both methods return bool , same family as a lot of the filesystem API. Same disk and same path will throw an InvalidArgumentException- which is the correct kind of loud when you've pointed the gun at your own foot.
Why I like this one
It's not a flashy feature by any means. Nobody's keynoting about disk-to-disk streaming. But this is the exact class of DX I care about. The boring glue. The stuff that shows up in export jobs, pipelines, "promote this temp upload to permanent storage" flows. The bits where a one-line in the framework means three fewer places for me to forget fclose.
I’ve macro'd variants of this on client apps. You've probably done the same. Now we can delete the macro and call the framework instead - which is always the outcome I'm quietly hoping for when I open a merged PR on a Friday.
Small PR. Properly useful. Exactly the sort of thing worth noticing.
If you've still got a Storage::macro('moveToDisk', …) kicking around from 2023, this is your gentle nudge to retire it with prejudice.