workdoneright/laravel-deletion-guard

Config-driven deletion guard for Laravel models

Maintainers

Package info

github.com/Work-Done-Right/laravel-deletion-guard

Homepage

pkg:composer/workdoneright/laravel-deletion-guard

Fund package maintenance!

abishekrsrikaanth

Statistics

Installs: 21

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-01-31 17:29 UTC

This package is auto-updated.

Last update: 2026-03-05 22:17:05 UTC


README

Latest Version on Packagist Total Downloads

A Laravel package that prevents Eloquent models from being deleted when they have dependent relationships with existing data. Stop accidental data loss by automatically blocking deletions when related records exist.

Features

  • ๐Ÿ›ก๏ธ Automatic Protection - Prevents deletion of models with dependent relationships
  • ๐ŸŽฏ Two Modes - Choose between explicit method-based or docblock annotation-based configuration
  • ๐Ÿš€ Performance Optimized - Built-in caching for relation discovery
  • ๐Ÿ”ง Flexible - Support for force delete, soft deletes, and custom error messages
  • ๐Ÿ“Š Record Counts - Automatically include counts in error messages (e.g., "47 posts, 123 comments")
  • ๐Ÿ—‘๏ธ Cascade Delete - Optionally auto-delete related records instead of blocking
  • ๐ŸŽš๏ธ Conditional Blocking - Block deletion only when specific conditions are met
  • ๐Ÿ“ Laravel Gates Compatible - Includes a policy class for authorization
  • ๐Ÿงช Audit Command - Test deletion blockers before attempting deletion

Installation

Install the package via composer:

composer require workdoneright/laravel-deletion-guard

Publish the config file:

php artisan vendor:publish --tag="laravel-deletion-guard-config"

Configuration

After publishing, the config file will be available at config/deletion-guard.php:

return [
    /*
    |--------------------------------------------------------------------------
    | Dependency Discovery Mode
    |--------------------------------------------------------------------------
    | Options:
    | - explicit   โ†’ Require explicit opt-in via deletionDependencies() method
    | - docblock   โ†’ Auto-discover via @deleteBlocker docblock annotations
    */
    'mode' => env('DELETE_DEPENDENCY_MODE', 'explicit'),

    /*
    |--------------------------------------------------------------------------
    | Cache
    |--------------------------------------------------------------------------
    | Cache relation discovery to improve performance
    */
    'cache' => [
        'enabled' => true,
        'store' => null, // uses default cache store
        'ttl' => 3600,
        'prefix' => 'delete_guard:',
    ],

    /*
    |--------------------------------------------------------------------------
    | Force Delete Override
    |--------------------------------------------------------------------------
    | Allow force delete to bypass deletion guards
    */
    'allow_force_delete' => true,

    /*
    |--------------------------------------------------------------------------
    | Include Count in Messages
    |--------------------------------------------------------------------------
    | When enabled, error messages will include the count of related records.
    | Example: "Cannot delete due to related posts. (5 records)"
    */
    'include_count' => true,
];

Usage

Choosing a Mode

The package supports two configuration modes:

Explicit Mode (Recommended)

  • โœ… Full feature support including conditional blocking
  • โœ… Type-safe with IDE autocomplete
  • โœ… More control and flexibility
  • โœ… Best for complex business logic

Docblock Mode

  • โœ… Clean, annotation-driven syntax
  • โœ… Less code, more readable
  • โœ… Great for simple blocking rules
  • โŒ No conditional blocking (security limitation)

See the Mode Comparison table below for detailed feature parity.

Basic Setup

Add the PreventsDeletionWithDependencies trait to your Eloquent models:

use Illuminate\Database\Eloquent\Model;
use WorkDoneRight\DeletionGuard\Concerns\PreventsDeletionWithDependencies;

class User extends Model
{
    use PreventsDeletionWithDependencies;

    public function posts()
    {
        return $this->hasMany(Post::class);
    }

    public function comments()
    {
        return $this->hasMany(Comment::class);
    }
}

Explicit Mode (Recommended)

Define which relationships should block deletion by implementing the deletionDependencies() method:

class User extends Model
{
    use PreventsDeletionWithDependencies;

    public function posts()
    {
        return $this->hasMany(Post::class);
    }

    public function comments()
    {
        return $this->hasMany(Comment::class);
    }

    /**
     * Define relationships that prevent deletion
     */
    protected function deletionDependencies(): array
    {
        return [
            'posts',    // Simple: just list the relation name
            'comments' => [
                'message' => 'Cannot delete user because they have comments.',
            ],
        ];
    }
}

Now when you try to delete a user with posts or comments:

$user = User::find(1);
$user->delete(); // Throws DeletionBlockedException if posts or comments exist

Docblock Mode

Set mode to 'docblock' in your config, then use annotations:

class User extends Model
{
    use PreventsDeletionWithDependencies;

    /**
     * @deleteBlocker
     * @deleteMessage Cannot delete user with existing posts
     */
    public function posts()
    {
        return $this->hasMany(Post::class);
    }

    /**
     * @deleteBlocker
     */
    public function comments()
    {
        return $this->hasMany(Comment::class);
    }
}

Docblock Mode Advanced Features

Docblock mode supports most features available in explicit mode:

class User extends Model
{
    use PreventsDeletionWithDependencies;

    /**
     * @deleteBlocker
     * @cascade
     * Auto-delete all posts when user is deleted
     */
    public function posts()
    {
        return $this->hasMany(Post::class);
    }

    /**
     * @deleteBlocker
     * @withTrashed
     * Include soft-deleted comments in the check
     */
    public function comments()
    {
        return $this->hasMany(Comment::class);
    }

    /**
     * @deleteBlocker
     * @noCount
     * @deleteMessage Cannot delete user with subscriptions
     * Disable automatic count in error message
     */
    public function subscriptions()
    {
        return $this->hasMany(Subscription::class);
    }
}

Available Docblock Annotations:

  • @deleteBlocker - Marks relation as a deletion blocker (required)
  • @deleteMessage <message> - Custom error message
  • @cascade - Auto-delete related records
  • @withTrashed - Include soft-deleted records in checks
  • @noCount - Disable count in error message for this relation

Note: Conditional blocking (condition callback) is not available in docblock mode for security reasons (would require eval()).

Mode Comparison: Feature Parity

Both modes support nearly all features, with one exception for security reasons:

Feature Explicit Mode Docblock Mode Example
Basic Blocking โœ… โœ… Prevent deletion when relations exist
Custom Messages โœ… โœ… 'message' => '...' vs @deleteMessage
Cascade Delete โœ… โœ… 'cascade' => true vs @cascade
Include Soft Deleted โœ… โœ… 'withTrashed' => true vs @withTrashed
Disable Count โœ… โœ… 'includeCount' => false vs @noCount
Count Placeholder โœ… โœ… Use :count in message
Force Delete Bypass โœ… โœ… Both modes respect config
Conditional Blocking โœ… โŒ 'condition' => fn($q) => ... only

Why No Conditional Blocking in Docblock Mode?

Conditional blocking requires executing PHP closures/callbacks that filter the query before checking for records:

// Explicit Mode - SAFE (closure defined in code)
'posts' => [
    'condition' => fn($query) => $query->where('published', true)
]

To support this in docblock mode would require either:

  1. Parsing and eval()ing PHP code from docblocks - Major security vulnerability
  2. A limited DSL for conditions - Complex to implement and restrictive

Since conditional blocking is an advanced feature and explicit mode provides a safe implementation, we've chosen to keep docblock mode simple and secure.

Recommendation: Use explicit mode if you need conditional blocking. Use docblock mode for simpler, annotation-driven configuration.

Advanced Options

Custom Error Messages

protected function deletionDependencies(): array
{
    return [
        'posts' => [
            'message' => 'This user has written posts. Please reassign or delete them first.',
        ],
        'subscriptions' => [
            'message' => 'Cannot delete user with active subscriptions.',
        ],
    ];
}

Including Record Counts

By default, error messages include the count of related records. You can customize this:

protected function deletionDependencies(): array
{
    return [
        'posts' => [
            'message' => 'Cannot delete user with :count posts',
            // Use :count placeholder to control where count appears
        ],
        'comments' => [
            'message' => 'User has comments',
            'includeCount' => false, // Disable count for this relation
        ],
    ];
}

Output examples:

  • With placeholder: "Cannot delete user with 47 posts"
  • Auto-appended: "User has comments (12 records)"
  • Disabled: "User has comments"

You can also disable counts globally in the config file:

// config/deletion-guard.php
'include_count' => false,

Including Soft Deleted Records

protected function deletionDependencies(): array
{
    return [
        'posts' => [
            'withTrashed' => true, // Check including soft-deleted posts
            'message' => 'Cannot delete user with posts (including deleted ones).',
        ],
    ];
}

Cascade Delete

Automatically delete related records instead of blocking:

protected function deletionDependencies(): array
{
    return [
        'posts' => [
            'cascade' => true, // Auto-delete all posts when user is deleted
        ],
        'comments' => [
            'cascade' => true,
            // No need for a message since deletion won't be blocked
        ],
        'subscriptions' => [
            // This will still block if subscriptions exist
            'message' => 'Cannot delete user with active subscriptions.',
        ],
    ];
}

Note: Cascade deletes are executed before checking blockers, so you can mix cascade and blocking rules.

Conditional Blocking

Block deletion only when specific conditions are met:

protected function deletionDependencies(): array
{
    return [
        'posts' => [
            'condition' => fn($query) => $query->where('published', true),
            'message' => 'Cannot delete user with published posts.',
        ],
        'subscriptions' => [
            'condition' => fn($query) => $query->where('status', 'active'),
            'message' => 'Cannot delete user with active subscriptions.',
        ],
        'orders' => [
            'condition' => fn($query) => $query->where('created_at', '>', now()->subDays(30)),
            'message' => 'Cannot delete user with recent orders.',
        ],
    ];
}

The condition callback receives the relationship query builder, allowing you to apply any filters before checking if records exist.

Exception Handling

The package throws a DeletionBlockedException (extends ValidationException) when deletion is blocked:

use WorkDoneRight\DeletionGuard\Exceptions\DeletionBlockedException;

try {
    $user->delete();
} catch (DeletionBlockedException $e) {
    // Get all blocker messages
    $messages = $e->errors()['delete'];

    // Example: ["Cannot delete due to related posts.", "Cannot delete due to related comments."]
    return response()->json(['errors' => $messages], 422);
}

Force Delete

When allow_force_delete is enabled in config, you can bypass guards using force delete:

// For soft-deletable models
$user->forceDelete(); // Bypasses deletion guard

// For regular models, force delete works the same as normal delete
// but will bypass the guard if allow_force_delete is true

Audit Command

Check what would block a deletion before attempting it:

php artisan deletion-guard:audit "App\\Models\\User" 1

Output:

โŒ Cannot delete due to related posts.
โŒ Cannot delete due to related comments.

Or if deletion is allowed:

โœ… No blockers found.

Authorization with Policies

The package includes a DeletionPolicy class for use with Laravel Gates:

use WorkDoneRight\DeletionGuard\Policies\DeletionPolicy;

class AuthServiceProvider extends ServiceProvider
{
    protected $policies = [
        User::class => DeletionPolicy::class,
    ];
}

The policy provides:

  • delete() - Returns false if deletion blockers exist
  • forceDelete() - Checks if user is admin ($user->is_admin)

Checking Blockers Programmatically

$user = User::find(1);

// Get all current blockers
$blockers = $user->deletionBlockers();

// Returns array like:
// [
//     ['relation' => 'posts', 'message' => 'Cannot delete due to related posts.'],
//     ['relation' => 'comments', 'message' => 'Cannot delete due to related comments.'],
// ]

// Check if deletion is safe
if (empty($blockers)) {
    $user->delete();
} else {
    // Handle blockers
    foreach ($blockers as $blocker) {
        echo $blocker['message'];
    }
}

How It Works

  1. The trait boots during model initialization and registers a deleting event listener
  2. Before deletion, it checks all configured dependencies
  3. For each dependency, it queries the relationship to see if related records exist
  4. If any blockers are found, it throws a DeletionBlockedException
  5. If allow_force_delete is enabled and forceDelete() is called, guards are bypassed

Performance Considerations

  • Caching: Relation discovery is cached (when in docblock mode) to avoid repeated reflection
  • Query Optimization: Only checks for existence, doesn't load full records
  • Lazy Evaluation: Only checks relationships when deletion is attempted

Requirements

  • PHP 8.2 or higher
  • Laravel 11.0 or 12.0

Testing

composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

Contributing

Please see CONTRIBUTING for details.

Security Vulnerabilities

Please review our security policy on how to report security vulnerabilities.

Credits

License

The MIT License (MIT). Please see License File for more information.