php 146 lines · 4 tabs

Soft-Delete and Restore Blog Posts with a Trash View in Laravel

Shared by codesnips Jul 2026
4 tabs
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('title');
            $table->string('slug')->unique();
            $table->longText('body');
            $table->timestamp('published_at')->nullable();
            $table->timestamps();
            $table->softDeletes();

            $table->index('deleted_at');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('posts');
    }
};
4 files · php Explain with highlit

This snippet shows how a Laravel blog implements reversible deletion: posts are hidden from normal queries but retained in the database, then surfaced in a trash view where they can be restored or permanently removed. The pattern avoids destructive DELETE statements for user-generated content, which protects against accidental loss and supports audit-style recovery.

The create_posts_table migration defines the schema and calls $table->softDeletes(), which adds a nullable deleted_at timestamp column. Eloquent treats a non-null deleted_at as "deleted": the row physically stays, but the default query scope filters it out. The migration also adds an index on deleted_at so trash queries and the implicit whereNull('deleted_at') on normal reads stay fast on large tables.

In the Post model, the SoftDeletes trait wires up the global scope and the restore(), forceDelete(), and trashed() helpers. Declaring deleted_at in $dates (via $casts) ensures it hydrates as a Carbon instance. The scopeOwnedBy local scope keeps ownership checks reusable, and restore() is exposed conceptually through the trait rather than reimplemented.

The PostController ties it together. destroy() calls $post->delete(), which under the trait performs a soft delete rather than a real removal. trash() uses Post::onlyTrashed() to list rows where deleted_at is set — the one place that inverts the default scope. Restoration and permanent deletion operate on trashed models fetched with withTrashed(), because a soft-deleted record is invisible to ordinary route-model binding and must be explicitly included. Each action runs authorize against a policy so only owners touch their posts, and onlyTrashed()->findOrFail() guards against restoring something that was never trashed.

The trash.blade.php view renders the recoverable posts, showing deleted_at->diffForHumans() and offering restore and force-delete forms. A key trade-off is that unique constraints still apply to soft-deleted rows, so a common pitfall is a slug collision when reusing a title from trash; teams often scope uniqueness with whereNull('deleted_at') or clear the slug on delete. Soft deletes fit content that users expect to recover; truly sensitive data may instead require hard deletion for compliance.


Related snips

Share this code

Here's the card — post it anywhere.

Soft-Delete and Restore Blog Posts with a Trash View in Laravel — share card
Link copied