Search by

vortech / softdeletes-flag

papp-mate

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.

Package info

github.com/Vortech-Group/softdeletes-flag

pkg:composer/vortech/softdeletes-flag

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.1.0 2026-10-15 12:54 UTC

This package is auto-updated.

Last update: 2026-10-15 12:55:37 UTC


README

SoftDeletesFlag by Vortech

Tests Latest version Downloads PHP version Laravel 13 License

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_at changes 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, visible or table.

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.

Vortech