Backing up user-uploaded files is just as important as backing up your database. In this tutorial, you’ll learn how to create a custom Laravel Artisan command that zips your storage/app/public
folder, optionally excluding certain files or directories (like .gitignore, assets, or even the backup folder itself), and stores the zip file in a downloadable location.
What We’ll Build
A custom Artisan command:
- Zips the entire
storage/app/public
folder - Excludes specific folders/files
- Stores the zip in
storage/app/public/backups
- Returns a public URL for downloading the backup
Folder Structure We’re Targeting
storage/
└── app/
└── public/
├── uploads/
├── images/
├── backups/ <-- We'll store zips here
├── assets/ <-- We might want to exclude this
└── .gitignore <-- Also excluded
The Artisan Command Code
php artisan make:command CreateStorageBackup
Then paste the following code in app/Console/Commands/CreateStorageBackup.php
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use ZipArchive;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
class CreateStorageBackup extends Command
{
protected $signature = 'create-storage-backup {--exclude=*}';
protected $description = 'Create a zip backup of storage/app/public';
public function handle()
{
$exclude = array_filter(
array_merge($this->option('exclude') ?? [], [
'.gitignore',
'backups', // backups are placed here
'assets', // theme files are placed here
])
);
$storagePath = storage_path('app/public');
$tempPath = storage_path('app/backup-temp');
$finalBackupPath = storage_path('app/public/backups');
// Ensure clean temp and final directories
if (!is_dir($tempPath))
mkdir($tempPath, 0755, true);
if (!is_dir($finalBackupPath))
mkdir($finalBackupPath, 0755, true);
$datetime = now()->format('Y-m-d-H-i-s');
$filename = "storage-$datetime.zip";
$tempZipPath = $tempPath . DIRECTORY_SEPARATOR . $filename;
$finalZipPath = $finalBackupPath . DIRECTORY_SEPARATOR . $filename;
$zip = new ZipArchive;
if ($zip->open($tempZipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
$this->error('Could not create zip file.');
return 1;
}
$this->addFolderToZip($zip, $storagePath, $exclude);
$zip->close();
// Move to final path
if (!rename($tempZipPath, $finalZipPath)) {
$this->error('Failed to move zip to final destination.');
return 1;
}
$url = Storage::url("backups/$filename");
$this->info("Backup created: " . url($url));
return 0;
}
protected function addFolderToZip(ZipArchive $zip, $sourcePath, $exclude = [])
{
$sourcePath = realpath($sourcePath);
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($sourcePath, \FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($files as $file) {
$filePath = $file->getRealPath();
$relativePath = str_replace('\\', '/', str_replace($sourcePath . DIRECTORY_SEPARATOR, '', $filePath));
foreach ($exclude as $ex) {
$ex = str_replace('\\', '/', trim($ex, '/'));
if (
$relativePath === $ex || // exact file or folder match
str_starts_with($relativePath, "$ex/") // folder with content
) {
continue 2;
}
}
if ($file->isDir()) {
$zip->addEmptyDir($relativePath);
} elseif ($file->isFile()) {
$zip->addFile($filePath, $relativePath);
}
}
}
}
How It Works
Excluding Folders and Files
We allow passing exclusions via --exclude
like this:
php artisan create-storage-backup --exclude=images --exclude=logo.png
But we also hardcode some common ones:
[
'.gitignore',
'backups', // backups go here
'assets', // theme files
]
The logic inside addFolderToZip
skips any file/folder that matches or starts with one of these entries.
Temporary Zip Creation
We create the zip file in a temporary path:
$tempPath = storage_path('app/backup-temp');
Then move it to:
$finalBackupPath = storage_path('app/public/backups');
This keeps the public folder clean and avoids zipping while writing.
Returning the Public URL
Once the zip is moved, we use Laravel’s Storage::url()
helper to generate a public URL:
$url = Storage::url("backups/$filename");
$this->info("Backup created: " . url($url));
Make sure your symbolic link to storage
is set up:
php artisan storage:link
Example Output
$ php artisan create-storage-backup
Backup created: http://your-app.test/storage/backups/storage-2025-04-25-17-35-52.zip
Optional: Listing All Old Backups
To list all backup zip files:
$files = Storage::files('backups');
$backups = array_filter($files, fn($f) => str_ends_with($f, '.zip'));
You could build another Artisan command or a controller endpoint to show available backups.
Final Tips
- This works seamlessly on both Linux and Windows
- For large backups, consider using queue workers or async jobs
- You can exclude more sensitive or unneeded files by adding them to the exclusion list
With this command, you’re now one step closer to automating file backups in your Laravel app. It’s a simple but powerful tool you can schedule via cron, expose via an admin dashboard, or even trigger via a webhook.
Automation
In the routes/console.php
file, write the code below and cron job will automaticall create backup.
use Illuminate\Support\Facades\Schedule;
Schedule::command('create-storage-backup')->dailyAt('23:59')->withoutOverlapping();