laravel/framework: Add copyToDisk & moveToDisk to FilesystemAdapter
Contributed by jackbayliss
Part of the framework v13.36.0 release
Laravel continues to round off the sharp edges of day-to-day storage work, and PR #61511 is a good example. It adds two new methods to Illuminate\Filesystem\FilesystemAdapter — copyToDisk() and moveToDisk() — which let you copy or move a file from one configured storage disk to another in a single call. Both methods are new in the 13.x series, so if you are upgrading from v12.70.0 you will gain them as soon as you move to a release containing this change. This is a pure feature addition, categorized as Feature: nothing existing changes behavior, and there are no breaking changes.
What's new: copyToDisk() and moveToDisk()
The methods share the same signature, with an optional destination path:
public function copyToDisk(string $disk, string $from, string|null $to = null): bool
public function moveToDisk(string $disk, string $from, string|null $to = null): bool
The first argument is the name of the destination disk as configured in config/filesystems.php, the second is the source path, and the third is an optional destination path that defaults to the same path on the target disk:
// Copy, keeping the original on the local disk
Storage::disk('local')->copyToDisk('backup', 'reports/report.csv');
// Move, deleting the original once the copy succeeds
Storage::disk('local')->moveToDisk('backup', 'reports/report.csv');
// Copy to a different path on the destination disk
Storage::disk('local')->copyToDisk('s3', 'reports/report.csv', '2026/report.csv');
moveToDisk() is implemented on top of copyToDisk(): the source file is only deleted after the copy returns true, so a failed transfer never leaves you without either copy.
Why it matters
Before this change, a disk-to-disk transfer was hand-rolled stream plumbing:
$stream = Storage::disk('local')->readStream('reports/report.csv');
Storage::disk('s3')->writeStream('reports/report.csv', $stream);
fclose($stream);
Storage::disk('local')->delete('reports/report.csv');
This works, but it is easy to get wrong. Forgetting fclose() leaks file handles — a real problem in long-running queue workers — and there is no protection if writeStream() throws mid-transfer. Because the logic is short but non-obvious, teams tend to duplicate it or wrap it in a macro, exactly as the PR author describes. Baking the pattern into the framework means one tested, resource-safe implementation everyone can rely on.
How it behaves: edge cases and guarantees
Under the hood, the new methods read a stream from the source adapter, write it to the destination, and close the stream in a finally block — so the handle is released even if the write fails. If the source file cannot be read, the methods return false rather than throwing. Two behaviors are worth knowing:
// Copying a file onto itself throws — there is nothing sensible to do
Storage::disk('s3')->copyToDisk('s3', 'a.txt');
// InvalidArgumentException: Cannot copy a file to the same disk and path.
// Same disk but a different path is fine — usable as a rename
Storage::disk('s3')->copyToDisk('s3', 'a.txt', 'archive/a.txt');
The guard compares the resolved adapter instances, so it only triggers for a genuine same-disk, same-path no-op. The destination disk is resolved through the FilesystemFactory contract from the container, which is what makes the destination configurable per call while still respecting your config/filesystems.php setup. The Storage facade docblock also gained both method signatures, so IDEs and static analyzers such as PHPStan and Psalm accept Storage::copyToDisk(...) / Storage::moveToDisk(...) calls against the default disk without complaining.
A practical example
The pattern shines in queued jobs, where generated artifacts often need to end up on cloud storage before the worker moves on:
class ArchiveReport implements ShouldQueue
{
public function __construct(public Report $report) {}
public function handle(): void
{
Storage::disk('local')
->moveToDisk('s3', "reports/{$this->report->filename}");
$this->report->update(['archived_at' => now()]);
}
}
If the upload to S3 throws, the stream is still closed, the local file is untouched, and the job can be retried safely — all properties that were previously your responsibility to implement by hand.
Upgrade impact
Upgrading from v12.70.0 requires no action to benefit from this change:
- The two methods are purely additive; existing
copy(),move(),readStream(), andwriteStream()calls behave exactly as before. - The
FilesystemFactorycontract the methods resolve is bound automatically in every standard Laravel application, so no configuration is needed. - One thing to review: if your codebase defined its own
copyToDisk()ormoveToDisk()macro onFilesystemAdapter(a common workaround, as the PR notes), native methods now take precedence when called on an adapter instance. Check any such macros for signature differences — especially argument order — before relying on the framework versions. - New tests cover copy, move, and the same-disk-and-path exception, so behavior is pinned down from day one.
Takeaways
copyToDisk()andmoveToDisk()are new methods onFilesystemAdapterfor one-line, disk-to-disk transfers in Laravel 13.x.- Both stream the file and guarantee the stream is closed via
finally, even on failure — safe for queue workers. moveToDisk()deletes the source only after a successful copy; a failed transfer changes nothing.- Copying a file to the same disk and path throws an
InvalidArgumentException; a different path on the same disk is allowed. - No breaking changes or upgrade steps — but audit any existing same-named macros for signature conflicts.