<?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']);
});
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Document extends Model
{
use SoftDeletes;
protected $fillable = ['title', 'body'];
public function user()
{
return $this->belongsTo(User::class);
}
public function scopeOwnedBy(Builder $query, int $userId): Builder
{
return $query->where('user_id', $userId);
}
}
<?php
namespace App\Http\Controllers;
use App\Models\Document;
use Illuminate\Http\Request;
class DocumentsController extends Controller
{
public function index(Request $request)
{
$documents = Document::ownedBy($request->user()->id)
->latest()
->paginate(20);
return view('documents.index', compact('documents'));
}
public function destroy(Request $request, Document $document)
{
$this->authorize('delete', $document);
$document->delete();
return redirect()
->route('documents.index')
->with('status', 'Document moved to trash.');
}
public function trashed(Request $request)
{
$documents = Document::onlyTrashed()
->ownedBy($request->user()->id)
->latest('deleted_at')
->paginate(20);
return view('documents.trashed', compact('documents'));
}
public function restore(Request $request, int $id)
{
$document = Document::onlyTrashed()
->ownedBy($request->user()->id)
->findOrFail($id);
$this->authorize('restore', $document);
$document->restore();
return redirect()
->route('documents.trashed')
->with('status', 'Document restored.');
}
}
<?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('documents', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->longText('body')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('documents');
}
};
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
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.