sykez/laravel-storage-copy
A small Laravel package for copying files and directories between storage disks, making it easy to move or duplicate content across local, S3, or other configured filesystems with a simple, fluent API.
This package is a thin wrapper: it registers one Artisan command that copies objects between two Laravel Filesystem disks. There is no service class, no facade, and no config file to learn — the command signature is the API. Treat your first ten minutes as reading that signature.
Install as a dev dependency unless you actually run it in production:
composer require --dev sykez/laravel-storage-copy
Package discovery registers the command automatically on Laravel 5.5+.
Discover the real interface before writing anything else:
php artisan list | grep -i storage
php artisan help storage:copy
Note the exact argument names (source/destination path, --disk/--from/--to, recursion flags, overwrite behavior). Every pattern below depends on these flags, so confirm them against the installed version rather than assuming.
Make sure both disks exist in config/filesystems.php and that credentials work independently:
php artisan tinker
>>> Storage::disk('local')->put('probe.txt', 'ok');
>>> Storage::disk('s3')->exists('probe.txt');
First use case — the one this package is genuinely good for: move a folder of local uploads into a fresh S3/CDN bucket during a storage migration, then keep the local disk intact as a rollback until you've verified object counts and sample downloads.
Disposable migration, not a sync engine. The happy path is a one-shot copy: source disk is authoritative, destination is empty, you run the command once, verify, then flip FILESYSTEM_DISK in .env. Do the copy while the app is in maintenance mode or with uploads disabled so nothing writes to the source mid-flight.
Seeding environments from a fixture disk. Keep a fixtures disk pointing at tests/fixtures/storage or a shared dev bucket, and copy it into local/public during test bootstrap. This is where the package is least risky — small trees, repeatable, no production data.
Publishing build artifacts. If your pipeline stages assets on one disk (e.g. a build container's local disk) and serves from another (s3 with a CDN in front), wrap the command in your deploy script:
Artisan::call('storage:copy', [
'source' => 'build/assets',
'destination' => 'assets/'.config('app.version'),
'--from' => 'local',
'--to' => 's3',
]);
Parameter names must match php artisan help output exactly; Artisan::call fails silently-ish on unknown keys.
Scheduling for backup mirroring. Register it as a scheduled command rather than a job:
$schedule->command('storage:copy ...')->dailyAt('02:00')->withoutOverlapping();
withoutOverlapping() matters — a slow copy that outlives its interval will otherwise stack.
Testing the copy. Use Storage::fake() for both disks, Artisan::call() the command, then assert with Storage::disk('dest')->assertExists(). This gives you a regression test on the flag names, which is the part most likely to break across upgrades.
Composition over extension. Since there are no events or hooks, build your own command that loops over disks/prefixes and calls the packaged one repeatedly. That keeps retry/logging/notification logic in your code instead of fighting the package.
uploads/2024) or need a leading slash, and whether the destination path is a directory or a target filename. Test with a single file before running on a directory.Storage::disk('s3')->setVisibility(...) over the copied keys.Cache-Control pass afterwards.rclone or aws s3 sync — this package is not that.max_execution_time under web-triggered Artisan or at your queue worker's timeout if wrapped in a job. Run from CLI with php -d memory_limit=-1 or split the work.php artisan config:clear locally — a stale cached config is the usual reason a "correctly configured" destination disk isn't found.composer.json and re-read help storage:copy after every update.Storage::disk($from)->allFiles() plus Storage::disk($to)->put() — the package's value is convenience, not capability, and it's roughly fifteen lines to replace.How can I help you explore Laravel packages today?