Series: Every Laravel Project Should Have These Building Blocks
Part 35 of 35 | Level: Beginner–Intermediate Prerequisites: None
The Problem
Files are harder to delete than rows. When you soft-delete a database record you expect the file to stick around – but when do you actually clean it up? And how do you serve private files without exposing real storage URLs?
Most Laravel apps answer this badly:
- Hard-delete the record → orphaned file sits on disk forever
- Soft-delete the record → file never moves, “deleted” media is still publicly accessible at its original URL
- Schedule a cleanup command → it skips broken records, nobody tests
--dry-run, the disk fills up quietly
This article covers the full lifecycle: soft-delete → auto-trash → scheduled prune → secure serving.
Part 1: SoftDeletes with Auto-Trash
The trick is using the model’s deleted event to move the physical file into a trash/ folder the moment the record is soft-deleted. The file is gone from normal access, but recoverable for 30 days.
// app/Models/Media.php <?php declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Storage; class Media extends Model { use SoftDeletes; protected $fillable = [ 'model_type', 'model_id', 'name', 'file_name', 'mime_type', 'path', 'disk', 'collection', 'size', ]; protected static function booted(): void { // On soft-delete: move file to trash/ folder static::deleted(function (self $media): void { if ($media->isForceDeleting()) { return; // handled separately in forceDeleted } $from = ltrim($media->path, '/'); $to = 'trash/' . $from; if (Storage::disk($media->disk)->exists($from)) { Storage::disk($media->disk)->move($from, $to); } else { logger()->warning("Media soft-delete: file not found at {$from}"); } }); // On restore: move file back from trash/ static::restored(function (self $media): void { $to = ltrim($media->path, '/'); $from = 'trash/' . $to; if (Storage::disk($media->disk)->exists($from)) { Storage::disk($media->disk)->move($from, $to); } }); // On force-delete: permanently remove from trash/ static::forceDeleted(function (self $media): void { $trashPath = 'trash/' . ltrim($media->path, '/'); if (Storage::disk($media->disk)->exists($trashPath)) { Storage::disk($media->disk)->delete($trashPath); } }); } public function model(): MorphTo { return $this->morphTo(); } } Why
trash/? It gives you a recovery window. A rogue bulk-delete doesn’t wipe everything permanently – you have 30 days (or however long your prune schedule runs) to recover.
Part 2: InteractsWithMedia Trait
Every model that owns files uses this trait. It handles upload, retrieval, and cascade delete automatically.
// app/Support/Traits/InteractsWithMedia.php <?php declare(strict_types=1); namespace App\Support\Traits; use App\Models\Media; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphOne; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Http\UploadedFile; use Illuminate\Support\Collection; use Illuminate\Support\Str; trait InteractsWithMedia { protected static function bootInteractsWithMedia(): void { static::deleted(function (self $model): void { // If the model uses SoftDeletes and is NOT force-deleting, // keep the media rows (they'll soft-delete themselves separately). if (in_array(SoftDeletes::class, class_uses_recursive($model), true) && ! $model->isForceDeleting()) { return; } // Hard delete or force delete — cascade to media rows (which triggers file moves) $model->medias()->each(fn (Media $m) => $m->delete()); }); } public function medias(): MorphMany { return $this->morphMany(Media::class, 'model'); } public function media(): MorphOne { return $this->morphOne(Media::class, 'model'); } public function addMedia(UploadedFile $file, string $collection = 'default'): Media { $uniqueName = Str::uuid() . '.' . $file->getClientOriginalExtension(); return $this->medias()->create([ 'name' => $file->getClientOriginalName(), 'file_name' => $uniqueName, 'mime_type' => $file->getMimeType(), 'path' => $file->storeAs($this->getMediaDirectory($collection), $uniqueName, $this->getMediaDisk()), 'disk' => $this->getMediaDisk(), 'collection'=> $collection, 'size' => $file->getSize(), ]); } public function addMedias(array $files, string $collection = 'default'): Collection { return collect($files)->map(fn ($file) => $this->addMedia($file, $collection)); } public function getFirstMedia(string $collection = 'default'): ?Media { return $this->medias()->where('collection', $collection)->first(); } public function getFirstMediaUrl(string $collection = 'default'): ?string { return $this->getFirstMedia($collection)?->full_url; } public function hasMedia(string $collection = 'default'): bool { return $this->medias()->where('collection', $collection)->exists(); } public function deleteMedia(string $collection = 'default'): void { $this->medias()->where('collection', $collection)->each(fn (Media $m) => $m->delete()); } protected function getMediaDirectory(string $collection): string { return 'uploads/' . class_basename($this) . '/' . $this->getKey(); } protected function getMediaDisk(): string { return 'public'; } } Usage in any model is one line:
class ServiceRequest extends Model { use SoftDeletes; use InteractsWithMedia; // Upload a file // $serviceRequest->addMedia($request->file('spec_sheet'), 'spec'); // Get URL // $serviceRequest->getFirstMediaUrl('spec'); } Part 3: Scheduled Prune – With --dry-run
Files in trash/ need to be cleaned up eventually. An Artisan command with --dry-run and --chunk lets you test before you destroy:
// app/Console/Commands/PruneDeletedMedia.php <?php declare(strict_types=1); namespace App\Console\Commands; use App\Models\Media; use Illuminate\Console\Command; use Illuminate\Support\Facades\Storage; class PruneDeletedMedia extends Command { protected $signature = 'media:prune {--days=30 : Force-delete media soft-deleted more than N days ago} {--chunk=100 : Records per chunk} {--dry-run : Show what would be deleted without doing it}'; protected $description = 'Permanently delete media soft-deleted more than --days ago.'; public function handle(): int { $days = (int) $this->option('days'); $chunk = (int) $this->option('chunk'); $dryRun = (bool) $this->option('dry-run'); $cutoff = now()->subDays($days); $count = 0; $this->info($dryRun ? "[dry-run] Would prune media soft-deleted before {$cutoff->toDateTimeString()}" : "Pruning media soft-deleted before {$cutoff->toDateTimeString()}" ); Media::onlyTrashed() ->where('deleted_at', '<', $cutoff) ->chunkById($chunk, function ($medias) use ($dryRun, &$count): void { foreach ($medias as $media) { if ($dryRun) { $this->line(" would delete: {$media->path} (disk: {$media->disk})"); } else { $media->forceDelete(); // triggers forceDeleted → removes from trash/ $this->line(" deleted: {$media->path}"); } $count++; } }); $this->info($dryRun ? "[dry-run] {$count} records would be permanently deleted." : "{$count} records permanently deleted." ); return self::SUCCESS; } } Schedule it daily in routes/console.php:
Schedule::command('media:prune --days=30')->dailyAt('03:00'); Test it safely first:
php artisan media:prune --days=30 --dry-run # [dry-run] Would prune media soft-deleted before 2025-05-29 03:00:00 # would delete: uploads/ServiceRequest/42/spec.pdf (disk: public) # [dry-run] 1 records would be permanently deleted. Part 4: Laravel’s Built-In Prunable
For simpler cases (no file cleanup needed – just DB rows), use Laravel’s Prunable interface directly on the model:
// app/Models/RequestLog.php <?php declare(strict_types=1); namespace App\Models; use Illuminate\Database\Eloquent\MassPrunable; use Illuminate\Database\Eloquent\Model; class RequestLog extends Model { use MassPrunable; public function prunable(): \Illuminate\Database\Eloquent\Builder { return static::where('created_at', '<', now()->subDays(90)); } } Then schedule:
Schedule::command('model:prune')->dailyAt('01:00'); Use
MassPrunablefor pure DB records (uses a single DELETE query). Use a custom command withforceDelete()whenever physical files need cleanup alongside DB rows.
Part 5: Secure Media Serving – The Proxy Pattern
Private files stored on a non-public disk can’t be served via a plain URL. The solution is a proxy route — a controller that authorises the request, then streams the file without exposing the real storage path.
The Simple Proxy (Signed Route)
For static documents and images, a signed closure route covers most cases:
// routes/web.php // Serves private files stored on local disk, public disk, or public/docs/ Route::get('/temporary-file/{path}', static function (string $path): mixed { if (Storage::disk('public')->exists($path)) { return Storage::disk('public')->response($path); } if (Storage::disk('local')->exists($path)) { return Storage::disk('local')->response($path); } $publicDocsPath = public_path('docs/' . $path); abort_unless(file_exists($publicDocsPath), 404); return response()->file($publicDocsPath); })->name('temporary.file') ->middleware('signed') ->where('path', '.+'); Generate a signed URL wherever you need to link a private file:
$url = URL::temporarySignedRoute( 'temporary.file', now()->addMinutes(30), ['path' => $media->path] ); The signed URL expires in 30 minutes. Requests with an invalid or expired signature are rejected by the signedmiddleware before the closure even runs.
The Full Media Proxy (Streaming + Range Support)
For video or large files where the browser needs to seek, you need HTTP Range header support:
// Dedicated media proxy — blocks direct tab navigation, supports Range headers Route::get('/media-proxy', [MediaProxyController::class, 'stream']) ->middleware(['auth', 'signed']) ->name('media.proxy'); // app/Http/Controllers/MediaProxyController.php <?php declare(strict_types=1); namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; class MediaProxyController extends Controller { /** * Stream a private file. * - 'v' query param holds the encrypted path (never expose the real path) * - Range headers are forwarded so video seeking works * - Sec-Fetch-Dest: document blocks direct browser-tab access */ public function stream(Request $request): mixed { // Block direct tab navigation (modern browsers send "document" here). // This only deters browsers: curl and scripts don't send the header. // The signed URL is the real protection. if ($request->header('Sec-Fetch-Dest') === 'document') { abort(403); } $path = ltrim(decrypt($request->query('v')), '/'); $disk = $request->query('disk', 'local'); if (! Storage::disk($disk)->exists($path)) { Log::warning('MediaProxy: file not found', ['path' => $path, 'disk' => $disk]); abort(404); } $mime = Storage::disk($disk)->mimeType($path); $size = Storage::disk($disk)->size($path); $range = $request->header('Range'); if ($range) { return $this->streamWithRange($path, $disk, $mime, $size, $range); } return Storage::disk($disk)->response($path); } private function streamWithRange( string $path, string $disk, string $mime, int $size, string $range ): \Symfony\Component\HttpFoundation\StreamedResponse { // Parse "bytes=start-end" preg_match('/bytes=(\d*)-(\d*)/', $range, $m); $start = (...