workdoneright / laravel-deletion-guard
Config-driven deletion guard for Laravel models
Package info
github.com/Work-Done-Right/laravel-deletion-guard
pkg:composer/workdoneright/laravel-deletion-guard
Fund package maintenance!
Requires
- php: ^8.2
- illuminate/contracts: ^11.0||^12.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.14
- nunomaduro/collision: ^8.8
- orchestra/testbench: ^10.0.0||^9.0.0
- pestphp/pest: ^4.0
- pestphp/pest-plugin-arch: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
- phpstan/extension-installer: ^1.4
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-phpunit: ^2.0
README
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:
- Parsing and eval()ing PHP code from docblocks - Major security vulnerability
- 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()- Returnsfalseif deletion blockers existforceDelete()- 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
- The trait boots during model initialization and registers a
deletingevent listener - Before deletion, it checks all configured dependencies
- For each dependency, it queries the relationship to see if related records exist
- If any blockers are found, it throws a
DeletionBlockedException - If
allow_force_deleteis enabled andforceDelete()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.