php 117 lines · 4 tabs

Soft Deletes with a Trash View and Restore Route in Laravel

Shared by codesnips Jul 2026
4 tabs
<?php

use App\Http\Controllers\DocumentsController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth')->group(function () {
    Route::get('documents/trash', [DocumentsController::class, 'trashed'])
        ->name('documents.trashed');

    Route::patch('documents/{id}/restore', [DocumentsController::class, 'restore'])
        ->name('documents.restore');

    Route::resource('documents', DocumentsController::class)
        ->except(['show']);
});
4 files · php Explain with highlit

This example implements the common "trash can" pattern in Laravel: records are never physically removed on a normal delete, they are flagged with a timestamp so they disappear from ordinary queries but can be listed in a trash view and restored later. It leans on Eloquent's built-in SoftDeletes support rather than a hand-rolled boolean flag, which keeps the query scoping automatic and avoids the classic bug of forgetting a where('deleted', false) clause somewhere.

The create_documents_table migration adds the column that makes it all work: $table->softDeletes() creates a nullable deleted_at timestamp. When that column is NULL the row is live; when it holds a timestamp the row is considered trashed. The migration also indexes the column implicitly through Eloquent's scoping, so listing live rows stays cheap.

The Document model opts in by using the SoftDeletes trait. That trait overrides delete() to set deleted_at instead of issuing a DELETE, and it registers a global scope so every query automatically appends whereNull('deleted_at'). To reach trashed rows a caller must explicitly use withTrashed() or onlyTrashed(), which is why the trash view can see records that are hidden everywhere else. The scopeOwnedBy helper narrows results to the current user so one account cannot browse another's trash.

The DocumentsController wires up the three relevant actions. destroy calls $document->delete(), which soft-deletes because of the trait. trashed builds its listing with onlyTrashed() so it shows exactly the discarded records. restore looks the row up through withTrashed() — necessary because the default scope would otherwise return a 404 for a trashed model — then calls restore() to clear deleted_at. Each action uses authorize so ownership is enforced by policy.

The routes/web.php file exposes the resource plus two extra endpoints: a GET route for the trash view and a PATCH route for restore. Restore uses PATCH rather than GET because it mutates state, keeping it safe from prefetching and crawlers. A trade-off worth noting: soft-deleted rows still occupy storage and can collide with unique constraints, so long-lived trash usually needs a scheduled purge of rows older than a retention window.


Related snips

Share this code

Here's the card — post it anywhere.

Soft Deletes with a Trash View and Restore Route in Laravel — share card
Link copied