vortech / softdeletes-flag
Drop-in soft deletes for Laravel Eloquent that use an indexed boolean flag instead of a nullable timestamp, for faster queries in high-load applications.
Requires
- php: ^8.3
- illuminate/support: ^13.0
Requires (Dev)
- orchestra/testbench: ^11.0
- pestphp/pest: ^4.0|^5.0
- pestphp/pest-plugin-laravel: ^4.0|^5.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Drop-in soft deletes for Laravel Eloquent that use an indexed boolean flag
instead of a nullable timestamp, for faster queries in high-load applications.
Why
Laravel's built-in SoftDeletes filters every query with WHERE deleted_at IS NULL. SoftDeletesFlag swaps that for a plain, indexed boolean:
SoftDeletes |
SoftDeletesFlag |
|
|---|---|---|
| Column | deleted_at TIMESTAMP NULL |
is_deleted BOOLEAN DEFAULT 0 |
| Live rows filter | deleted_at IS NULL |
is_deleted = 0 |
| Index | on a high-cardinality timestamp | on a two-value flag |
| Knows when it was deleted | yes | no |
If you need the deletion time, keep using SoftDeletes. If you only need to know whether a row is deleted, this package gives you the same API with a cheaper filter.
Requirements
- PHP 8.3+
- Laravel 13
Installation
composer require vortech/softdeletes-flag
The service provider is registered automatically through package discovery. Then run the install command to publish the config file:
php artisan softdeletes-flag:install
Use --force to overwrite an existing config file.
Usage
1. Add the column
Schema::create('posts', function (Blueprint $table) { $table->id(); $table->string('title'); $table->softDeletesFlag(); $table->timestamps(); });
softDeletesFlag() adds an indexed boolean column that defaults to false. To remove it in down():
$table->dropSoftDeletesFlag();
This drops the index together with the column.
2. Add the trait
use Illuminate\Database\Eloquent\Model; use Vortech\SoftDeletesFlag\Traits\SoftDeletesFlag; class Post extends Model { use SoftDeletesFlag; }
That is all. If you know Laravel's SoftDeletes, you already know this.
API
$post->delete(); // sets is_deleted = true $post->trashed(); // true $post->restore(); // sets is_deleted = false $post->forceDelete(); // really deletes the row Post::all(); // live rows only Post::withTrashed()->get(); // live + trashed Post::onlyTrashed()->get(); // trashed only Post::withoutTrashed()->get(); // live only, explicitly Post::onlyTrashed()->restore(); // bulk restore Post::where('author_id', 1)->delete(); // bulk soft delete Post::where('author_id', 1)->forceDelete(); // bulk hard delete Post::restoreOrCreate(['title' => 'Hello']); // restore a trashed match, or create Post::createOrRestore(['title' => 'Hello']); // create, or restore on a unique conflict
Events
trashed, restoring, restored, forceDeleting and forceDeleted are fired, just like with SoftDeletes. Returning false from restoring or forceDeleting cancels the operation.
Post::softDeleted(fn (Post $post) => Log::info("Trashed {$post->id}")); Post::restored(fn (Post $post) => Log::info("Restored {$post->id}"));
Quiet variants are available too: restoreQuietly() and forceDeleteQuietly().
Route model binding
Implicit route model binding skips trashed models and returns a 404. Opt in per route with withTrashed():
Route::get('/posts/{post}', ShowPost::class); // trashed → 404 Route::get('/admin/posts/{post}', ShowPost::class)->withTrashed(); // trashed → resolved
Scoped bindings, custom binding fields ({post:slug}) and backed enums work as usual.
Migrating from SoftDeletes
Already have tables with deleted_at? Generate migrations that add the flag and backfill it:
php artisan softdeletes-flag:convert posts comments # named tables php artisan softdeletes-flag:convert --all # every table with a deleted_at column php artisan softdeletes-flag:convert --all --except=failed_jobs
Each table gets its own migration, so you can edit a large table's backfill or roll tables back independently. The files are numbered one second apart, in the order you listed the tables. With --all the command lists the tables it found and asks for confirmation first. A table that doesn't exist, has no timestamp column or already has the flag column is skipped with a warning, and the rest are still generated.
Each migration adds the indexed flag column, sets it to true for every row where deleted_at is not null, and keeps deleted_at untouched. Review them, then run php artisan migrate. Options:
| Option | Default | |
|---|---|---|
--all |
convert every table that has the timestamp column and no flag column | |
--except= |
tables to leave out (repeatable) | |
--column= |
the configured column |
name of the flag column to add |
--timestamp-column= |
deleted_at |
the existing SoftDeletes column to read from |
--path= |
database/migrations |
where to write the files |
Then swap SoftDeletes for SoftDeletesFlag on the model. To keep filling the kept column from now on, enable the deletion timestamp with timestamp_column set to deleted_at.
For a zero-downtime switch: run the migration, deploy the new code, then run the backfill once more (each migration's up() is safe to repeat) to catch rows that were trashed in between. The backfill is a single UPDATE, so on very large tables edit that table's generated file to chunk it.
Configuration
The column defaults to is_deleted. To change it, edit the config file published by softdeletes-flag:install (or publish it manually with php artisan vendor:publish --tag=softdeletes-config):
// config/softdeletes-flag.php return [ 'column' => 'is_deleted', ];
Set the name before running your migrations. Changing it later means renaming the column yourself.
To use a different column on a single model, use the #[IsDeletedColumn] attribute or a $isDeletedColumn property. Pick whichever you prefer. If both are set, the attribute wins; a model-level setting always beats the config.
use Vortech\SoftDeletesFlag\Attributes\IsDeletedColumn; #[IsDeletedColumn('archived')] class Article extends Model { use SoftDeletesFlag; } class Page extends Model { use SoftDeletesFlag; protected string $isDeletedColumn = 'concealed'; }
Pass the same name to the migration helpers:
$table->softDeletesFlag('archived'); $table->dropSoftDeletesFlag('archived');
Deletion timestamp
The flag stays the only thing queries filter on, so lookups keep the cheap indexed check. You can optionally store when a row was deleted in an extra nullable timestamp column. It is informational only: it is set on delete() and cleared on restore(), including the bulk variants.
It is off by default. Turn it on globally in the config:
// config/softdeletes-flag.php 'timestamp_column' => 'deleted_at',
Or per model, with the attribute or a property (attribute first, then property, then config):
use Vortech\SoftDeletesFlag\Attributes\IsDeletedColumn; #[IsDeletedColumn('archived', withTimestamp: true)] // timestamp column: deleted_at class Article extends Model { use SoftDeletesFlag; } #[IsDeletedColumn('archived', withTimestamp: true, timestampColumn: 'archived_at')] class Report extends Model { use SoftDeletesFlag; } class Page extends Model { use SoftDeletesFlag; protected string $timestampColumn = 'concealed_at'; }
withTimestamp defaults to false, and timestampColumn defaults to deleted_at. Leaving withTimestamp off does not switch off a timestamp column enabled by the config or the property.
The migration helpers add and drop the column too. With the config set, no arguments are needed:
$table->softDeletesFlag(); // is_deleted (+ deleted_at from config) $table->softDeletesFlag('archived', 'archived_at'); // explicit names $table->dropSoftDeletesFlag('archived', 'archived_at');
The column is cast to a date automatically and indexed, so you can filter or sort on it (for example to purge rows trashed long ago) without a table scan. The flag filter itself never uses it.
Good to know
- Unique indexes. A trashed row still occupies its unique value. Use
restoreOrCreate()/createOrRestore()to bring the old row back instead of inserting a duplicate. - No deletion timestamp by default. Only
updated_atchanges on delete. Enable the optional deletion timestamp if you need to know when a row was deleted. - Reserved names. The column name must not clash with an Eloquent property such as
hidden,visibleortable.
Testing
composer test
The suite uses Pest and runs on PHP 8.3, 8.4 and 8.5 in CI.
Changelog
See CHANGELOG for what has changed recently.
Security
If you discover a security issue, please email mate@vortech.hu instead of using the issue tracker.
Credits
- Mate Papp, Developer @ Vortech
License
The MIT License (MIT). See the License File for more information.