<?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');
}
};
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Post extends Model
{
use SoftDeletes;
protected $fillable = ['user_id', 'title', 'slug', 'body', 'published_at'];
protected $casts = [
'published_at' => 'datetime',
'deleted_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function scopeOwnedBy(Builder $query, int $userId): Builder
{
return $query->where('user_id', $userId);
}
public function scopePublished(Builder $query): Builder
{
return $query->whereNotNull('published_at');
}
}
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
class PostController extends Controller
{
public function destroy(Post $post)
{
$this->authorize('delete', $post);
$post->delete(); // soft delete: sets deleted_at
return redirect()->route('posts.index')
->with('status', 'Post moved to trash.');
}
public function trash(Request $request)
{
$posts = Post::onlyTrashed()
->ownedBy($request->user()->id)
->latest('deleted_at')
->paginate(15);
return view('posts.trash', compact('posts'));
}
public function restore(int $id)
{
$post = Post::onlyTrashed()->findOrFail($id);
$this->authorize('restore', $post);
$post->restore();
return redirect()->route('posts.trash')
->with('status', 'Post restored.');
}
public function forceDelete(int $id)
{
$post = Post::onlyTrashed()->findOrFail($id);
$this->authorize('forceDelete', $post);
$post->forceDelete(); // permanent removal
return redirect()->route('posts.trash')
->with('status', 'Post permanently deleted.');
}
}
@extends('layouts.app')
@section('content')
<h1>Trash</h1>
@if (session('status'))
<div class="alert">{{ session('status') }}</div>
@endif
@forelse ($posts as $post)
<div class="trash-row">
<span class="title">{{ $post->title }}</span>
<span class="meta">deleted {{ $post->deleted_at->diffForHumans() }}</span>
<form method="POST" action="{{ route('posts.restore', $post->id) }}">
@csrf
@method('PATCH')
<button type="submit">Restore</button>
</form>
<form method="POST" action="{{ route('posts.forceDelete', $post->id) }}"
onsubmit="return confirm('Delete forever?');">
@csrf
@method('DELETE')
<button type="submit" class="danger">Delete forever</button>
</form>
</div>
@empty
<p>Trash is empty.</p>
@endforelse
{{ $posts->links() }}
@endsection
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
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
Django URL namespacing and reverse lookups
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form"]
static values = { delay: { type: Number, default: 250 } }
Debounced live search with Stimulus + Turbo Streams
<?php
namespace App\Providers;
use App\Contracts\PaymentGateway;
use App\Services\StripePaymentGateway;
Laravel service container and dependency injection
class AddSettingsToAccounts < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :accounts, :settings, :jsonb, null: false, default: {}
Postgres JSONB Partial Index for Feature Flags
{
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build"
},
Laravel mix/Vite for asset compilation
Share this code
Here's the card — post it anywhere.