Laravel Soft Deletes + File Trash & Secure Media Serving

Laravel Soft Deletes + File Trash & Secure Media Serving

Reading Time: 6 minutes

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
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 MassPrunable for pure DB records (uses a single DELETE query). Use a custom command with forceDelete()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
  • InteractsWithMedia centralises upload/delete logic so every model uses the same pattern
  • Prune commands with --dry-run let 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.

← Laravel Pipeline Pattern | Back to the Series Overview →

Laravel Soft Deletes + File Trash & Secure Media Serving

Oh hi there 👋
It’s nice to meet you.

Sign up to receive awesome content in your inbox.

We don’t spam! Read our privacy policy for more info.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

One Comment