tenantcloud/laravel-boolean-softdeletes
Laravel soft deletes backed by a boolean column instead of a nullable timestamp. A drop-in trait keeps the familiar SoftDeletes API while making delete checks and indexes faster and simpler, ideal for high-volume tables where a deleted flag beats deleted_at comparisons.
composer require tenantcloud/laravel-boolean-softdeletes
v7.0 requires Laravel 12 (see Gotchas — if you're on 10/11, stay on v6). Publish the config so you can see the column name and defaults:
php artisan vendor:publish --tag=boolean-softdeletes-config
Add the flag column to the table (either the package's migration helper if available in your version, or plainly):
$table->boolean('is_deleted')->default(false)->index();
Then swap Laravel's SoftDeletes for the package trait on the model — use one or the other, never both:
use Illuminate\Database\Eloquent\Model;
use TenantCloud\LaravelBooleanSoftdeletes\Traits\BooleanSoftdeletes;
class Invoice extends Model
{
use BooleanSoftdeletes;
}
First use case to validate: pick one high-write table where deleted_at IS NULL filtering was showing up in slow-query logs. Swap the trait, run your existing test suite, and compare EXPLAIN on your hottest list query. That single check tells you whether the migration is worth the blast radius before you touch other models.
Reads are the same, writes change shape. Model::query() is automatically scoped to is_deleted = false. withTrashed(), onlyTrashed(), trashed(), restore(), and forceDelete() all keep their familiar names, so most call sites need zero edits — the breakage is concentrated in code that touches deleted_at directly.
Bulk operations must be per-model. Model::where(...)->delete() issues a real DELETE and bypasses the trait entirely. For soft deletion of many rows:
Invoice::where('status', 'void')->each(fn ($i) => $i->delete());
// or, when you genuinely don't need events:
Invoice::where('status', 'void')->update(['is_deleted' => true]);
Prefer the first form when observers, queued jobs, or audit logs hang off the deleted event.
Unique constraints need rethinking. A row with is_deleted = true still physically exists, so unique(email) now blocks re-registration. Options, in order of preference:
CREATE UNIQUE INDEX ... ON users (email) WHERE is_deleted = false;unique(email, is_deleted) — works for one deleted copy only, not for repeat deletionsdeleting observerDecide this before the migration, not after the first support ticket.
Index the pair, not the flag. A lone boolean index is low-selectivity and most planners will ignore it. Use composite indexes matching your real queries — (is_deleted, created_at), (tenant_id, is_deleted) — or a partial index on Postgres.
Route model binding and API resources come along for free. Implicit binding resolves through the global scope, so trashed records 404 as expected. If your resources exposed deleted_at in JSON, replace it with an explicit is_deleted boolean rather than letting a null leak.
Testing. Replace assertSoftDeleted usage with database assertions:
$this->assertDatabaseHas('invoices', ['id' => $invoice->id, 'is_deleted' => true]);
Consider a small custom assertion helper in your base TestCase so the migration is a one-line change across the suite.
Retention/pruning. There is no scheduler hook. Pair the trait with a scheduled forceDelete() job for rows past your retention window, otherwise the boolean approach quietly turns into "never actually deleted."
assertSoftDeleted() / assertNotSoftDeleted() will fail. They assert on deleted_at, which no longer exists. This is the single most common source of red tests after the swap.$model->deleted_at breaks. Attribute access, Blade templates, API resources, admin filters, CSV exports. Grep for deleted_at repo-wide before merging — including in packages, Nova/Filament resources, and raw query builders.DB::table() bypass the global scope. If you have reporting queries on the query builder, add ->where('is_deleted', false) by hand. The trait only protects Eloquent.0/1; without protected $casts = ['is_deleted' => 'boolean'] (or the trait's own casting) your JSON and if checks get surprising.php artisan config:cache (and clear it in CI) or you'll get "unknown column" errors that look like a broken migration.forceDelete() is irreversible and doesn't warn. In an admin panel this is one careless click from data loss; add confirmation and authorization gates.deleting/deleted/restoring/restored behave normally, which makes observers your cleanest extension point — use deleting to cascade flags to children, since there's no DB-level cascade for a boolean flip.->withTrashed() to confirm it was the scope rather than a hard delete, then check is_deleted directly with DB::table() to rule out a raw query setting the flag.How can I help you explore Laravel packages today?