Laravel Soft Deletes + File Trash & Secure Media Serving
Series: Every Laravel Project Should Have These Building Blocks
Part 35 of 35 | Level: Beginner–Intermediate Prerequisites: None
Table of Contents
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
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
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
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
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
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)
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 = (int) ($m[1] ?: 0);
$end = (int) ($m[2] ?: $size - 1);
$length = $end - $start + 1;
return response()->stream(function () use ($path, $disk, $start, $length): void {
$stream = Storage::disk($disk)->readStream($path);
fseek($stream, $start);
echo fread($stream, $length);
fclose($stream);
}, 206, [
'Content-Type' => $mime,
'Content-Length' => $length,
'Content-Range' => "bytes {$start}-{$end}/{$size}",
'Accept-Ranges' => 'bytes',
'Cache-Control' => 'no-store, private',
]);
}
}
Generate signed URLs for streaming media:
$url = URL::temporarySignedRoute(
'media.proxy',
now()->addMinutes(30),
['v' => encrypt($media->path), 'disk' => $media->disk]
);
The real storage path is encrypted inside the v parameter — the browser never sees it. The signed URL expires in 30 minutes, and the Sec-Fetch-Dest: document check blocks anyone who pastes the URL directly into a browser tab.
The Complete File Lifecycle
Upload
└─→ storeAs() on disk → Media row created
Soft delete model
└─→ Media::deleted event fires
└─→ file moves: uploads/x.pdf → trash/uploads/x.pdf
Media row: deleted_at = now()
Restore model
└─→ Media::restored event fires
└─→ file moves: trash/uploads/x.pdf → uploads/x.pdf
Media row: deleted_at = null
media:prune (after 30 days)
└─→ Media::forceDeleted event fires
└─→ Storage::delete('trash/uploads/x.pdf')
Media row: removed permanently
Testing the Full Cycle
// tests/Feature/MediaLifecycleTest.php
public function test_soft_deleting_media_moves_file_to_trash(): void
{
Storage::fake('public');
$media = Media::factory()->create(['path' => 'uploads/test.pdf', 'disk' => 'public']);
Storage::disk('public')->put('uploads/test.pdf', 'content');
$media->delete();
Storage::disk('public')->assertMissing('uploads/test.pdf');
Storage::disk('public')->assertExists('trash/uploads/test.pdf');
$this->assertSoftDeleted($media);
}
public function test_restoring_media_moves_file_back(): void
{
Storage::fake('public');
$media = Media::factory()->trashed()->create(['path' => 'uploads/test.pdf', 'disk' => 'public']);
Storage::disk('public')->put('trash/uploads/test.pdf', 'content');
$media->restore();
Storage::disk('public')->assertExists('uploads/test.pdf');
Storage::disk('public')->assertMissing('trash/uploads/test.pdf');
}
public function test_force_deleting_removes_file_from_trash(): void
{
Storage::fake('public');
$media = Media::factory()->trashed()->create(['path' => 'uploads/test.pdf', 'disk' => 'public']);
Storage::disk('public')->put('trash/uploads/test.pdf', 'content');
$media->forceDelete();
Storage::disk('public')->assertMissing('trash/uploads/test.pdf');
$this->assertModelMissing($media);
}
FAQ
Does soft delete remove the file in Laravel?
No. SoftDeletes only sets deleted_at on the row — the file stays at its public URL. Move it to a trash/ folder in the model’s deleted event, as shown above.
How do I permanently delete soft-deleted files?
Schedule a prune command that calls forceDelete() on records older than your retention window. Always test with --dry-run first.
How do I serve private files in Laravel?
Never expose storage paths. Use a signed proxy route that authorizes the request and streams the file — with Range header support if you serve video.
Key Takeaways
- Soft-delete + auto-trash gives you a recovery window without leaving files publicly accessible
InteractsWithMediacentralises upload/delete logic so every model uses the same pattern- Prune commands with
--dry-runlet you safely test before destroying production data - The proxy pattern (encrypted path + signed URL + auth middleware) keeps private files private
- Range header support in the proxy lets videos seek without full re-download
Resources
End of Series. You’ve covered all 35 building blocks — from folder structure to file lifecycle management. Every pattern here comes from real production Laravel applications.
One Comment