├── composer.json ├── LICENSE ├── src ├── CascadeDeletes.php └── CascadeDeletesExtension.php └── README.md /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sofa/eloquent-cascade", 3 | "description": "Cascading deletes for Laravel Eloquent ORM.", 4 | "license": "MIT", 5 | "support": { 6 | "issues": "https://github.com/jarektkaczyk/eloquent-cascade/issues", 7 | "source": "https://github.com/jarektkaczyk/eloquent-cascade" 8 | }, 9 | "keywords": [ 10 | "laravel", 11 | "eloquent", 12 | "cascade", 13 | "cascading deletes", 14 | "deletes" 15 | ], 16 | "authors": [ 17 | { 18 | "name": "Jarek Tkaczyk", 19 | "email": "jarek@softonsofa.com", 20 | "homepage": "http://softonsofa.com/", 21 | "role": "Developer" 22 | } 23 | ], 24 | "require": { 25 | "php": ">=7.2.5", 26 | "illuminate/database": ">=6.20.14" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Sofa\\EloquentCascade\\": "src" 31 | } 32 | }, 33 | "minimum-stability": "stable" 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Jarek Tkaczyk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/CascadeDeletes.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | trait CascadeDeletes 17 | { 18 | /** 19 | * Boot the trait. 20 | * 21 | * @return void 22 | */ 23 | protected static function bootCascadeDeletes() 24 | { 25 | static::addGlobalScope(new CascadeDeletesExtension); 26 | 27 | static::registerDeletedHandler(); 28 | 29 | if (static::usesSoftDeletes()) { 30 | static::registerRestoredHandler(); 31 | } 32 | } 33 | 34 | /** 35 | * Register handler for cascade deletes. 36 | * 37 | * @return void 38 | */ 39 | protected static function registerDeletedHandler() 40 | { 41 | static::deleted(function ($model) { 42 | $action = self::wasSoftDeleted($model) ? 'delete' : 'forceDelete'; 43 | 44 | foreach ($model->deletesWith() as $relation) { 45 | $model->{$relation}()->get()->each(function ($related) use ($action) { 46 | $related->{$action}(); 47 | }); 48 | } 49 | }); 50 | } 51 | 52 | /** 53 | * Register handler for cascade restores. 54 | * 55 | * @return void 56 | */ 57 | protected static function registerRestoredHandler() 58 | { 59 | static::restored(function ($model) { 60 | foreach ($model->deletesWith() as $relation_name) { 61 | $relation = $model->{$relation_name}(); 62 | 63 | if ($relation->getMacro('onlyTrashed')) { 64 | $related = $relation->getRelated(); 65 | 66 | $parent_deleted_at = $model->getAttribute($model->getDeletedAtColumn()); 67 | 68 | $relation->onlyTrashed() 69 | // This will ensure we don't restore models that had been deleted before this model 70 | ->where($related->getQualifiedDeletedAtColumn(), '>=', $parent_deleted_at) 71 | ->get() 72 | ->each(function ($related) { 73 | $related->restore(); 74 | }); 75 | } 76 | } 77 | }); 78 | } 79 | 80 | /** 81 | * Get array of relations to delete along with this model. 82 | * 83 | * @return array 84 | */ 85 | public function deletesWith() 86 | { 87 | return property_exists($this, 'deletesWith') ? $this->deletesWith : []; 88 | } 89 | 90 | /** 91 | * Determine whether the model was soft deleted. 92 | * 93 | * @param \Illuminate\Database\Eloquent\Model $model 94 | * @return boolean 95 | */ 96 | protected static function wasSoftDeleted($model) 97 | { 98 | return static::usesSoftDeletes() && $model->{$model->getDeletedAtColumn()}; 99 | } 100 | 101 | /** 102 | * Determine whether the model uses soft deletes. 103 | * 104 | * @return boolean 105 | */ 106 | protected static function usesSoftDeletes() 107 | { 108 | return in_array(SoftDeletes::class, class_uses_recursive(static::class)); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/CascadeDeletesExtension.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | class CascadeDeletesExtension implements Scope 18 | { 19 | /** 20 | * Extend the query builder with the needed functions. 21 | * 22 | * @param \Illuminate\Database\Eloquent\Builder $builder 23 | * @return void 24 | */ 25 | public function extend(Builder $builder) 26 | { 27 | $this->registerDeletedHandler($builder); 28 | 29 | if ($this->usesSoftDeletes($builder)) { 30 | $this->registerRestoredHandler($builder); 31 | } 32 | } 33 | 34 | /** 35 | * Register handler for cascade restores. 36 | * 37 | * @param \Illuminate\Database\Eloquent\Builder $builder 38 | * @return void 39 | */ 40 | protected function registerRestoredHandler(Builder $builder) 41 | { 42 | // Here we override restore macro in order to add required behaviour. 43 | $builder->macro('restore', function (Builder $builder) { 44 | $model = $builder->getModel(); 45 | 46 | collect($model->deletesWith()) 47 | ->filter(function ($relation_name) use ($model) { 48 | return $this->usesSoftDeletes($model->{$relation_name}()); 49 | })->each(function ($relation_name) use ($builder) { 50 | // It is a bit tricky to achieve expected result which is restoring only those children that were 51 | // delete along with the parent model (not before). We cannot easily achieve that on the query 52 | // level, so we'll simply run N queries here. Should be fine as this is an edge case anyway. 53 | $restored_models = $builder->onlyTrashed()->get(); 54 | 55 | foreach ($restored_models as $restored_model) { 56 | $relation = $restored_model->{$relation_name}(); 57 | $related = $relation->getRelated(); 58 | 59 | $parent_deleted_at = $restored_model->getAttribute($restored_model->getDeletedAtColumn()); 60 | 61 | $relation 62 | ->where($related->getQualifiedDeletedAtColumn(), '>=', $parent_deleted_at) 63 | ->restore(); 64 | } 65 | }); 66 | 67 | return $builder->update([$builder->getModel()->getDeletedAtColumn() => null]); 68 | }); 69 | } 70 | 71 | /** 72 | * Register handler for cascade deletes. 73 | * 74 | * @param \Illuminate\Database\Eloquent\Builder $builder [description] 75 | * @return void 76 | */ 77 | protected function registerDeletedHandler(Builder $builder) 78 | { 79 | $builder->onDelete(function (Builder $builder) { 80 | $model = $builder->getModel(); 81 | 82 | if (!empty($model->deletesWith())) { 83 | $deleted = $builder->get()->all(); 84 | 85 | // In order to get relation query with correct constraint applied we have 86 | // to mimic eager loading 'where KEY in' behaviour rather than default 87 | // constraints for single model which would be invalid in this case. 88 | Relation::noConstraints(function () use ($model, $deleted) { 89 | foreach ($model->deletesWith() as $relation) { 90 | $query = $model->{$relation}(); 91 | $query->addEagerConstraints($deleted); 92 | $query->delete(); 93 | } 94 | }); 95 | } 96 | 97 | return $this->performDelete($builder); 98 | }); 99 | } 100 | 101 | /** 102 | * Perform delete on the builder. 103 | * 104 | * @param \Illuminate\Database\Eloquent\Builder $builder 105 | * @return integer 106 | */ 107 | protected function performDelete($builder) 108 | { 109 | if ($this->usesSoftDeletes($builder)) { 110 | $column = $this->getDeletedAtColumn($builder); 111 | 112 | return $builder->update([ 113 | $column => $builder->getModel()->freshTimestampString(), 114 | ]); 115 | } 116 | 117 | return $builder->toBase()->delete(); 118 | } 119 | 120 | /** 121 | * Determine whether builder soft deletes. 122 | * 123 | * @param \Illuminate\Database\Eloquent\Builder $builder 124 | * @return boolean 125 | */ 126 | protected function usesSoftDeletes($builder) 127 | { 128 | return in_array( 129 | SoftDeletes::class, 130 | class_uses_recursive(get_class($builder->getModel())) 131 | ); 132 | } 133 | 134 | /** 135 | * Get the "deleted at" column for the builder. 136 | * 137 | * @param \Illuminate\Database\Eloquent\Builder $builder 138 | * @return string 139 | */ 140 | protected function getDeletedAtColumn($builder) 141 | { 142 | if (!empty($builder->getQuery()->joins)) { 143 | return $builder->getModel()->getQualifiedDeletedAtColumn(); 144 | } else { 145 | return $builder->getModel()->getDeletedAtColumn(); 146 | } 147 | } 148 | 149 | /** 150 | * Nothing here, just to satisfy the interface. 151 | */ 152 | public function apply(Builder $builder, Model $model) 153 | { 154 | // 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sofa/EloquentCascade 2 | 3 | [![Downloads](https://poser.pugx.org/sofa/eloquent-cascade/downloads)](https://packagist.org/packages/sofa/eloquent-cascade) [![stable](https://poser.pugx.org/sofa/eloquent-cascade/v/stable.svg)](https://packagist.org/packages/sofa/eloquent-cascade) 4 | 5 | Cascading (soft / hard) deletes for the [Eloquent ORM (Laravel 5.0+)](https://laravel.com/docs/eloquent). 6 | 7 | **Why use this one?** There are couple of packages that already provide cascading deletes for eloquent, but none of them works with the query builder. Here you get support for both `$model->delete()` and `$query->delete()`, as well as `$model->forceDelete()`. 8 | 9 | * [simple usage](#simple) 10 | * [using with `SoftDeletes`](#using-with-softdeletes) 11 | * [CHANGELOG](#changelog) 12 | 13 | ## Installation 14 | 15 | Package goes along with Laravel (Illuminate) versioning, in order to make it easy for you to pick appropriate version: 16 | 17 | Laravel / Illuminate **5.2+**: 18 | 19 | ``` 20 | composer require sofa/eloquent-cascade:"~5.2" 21 | ``` 22 | 23 | Laravel / Illuminate **5.0/5.1**: 24 | 25 | ``` 26 | composer require sofa/eloquent-cascade:"~5.1" 27 | ``` 28 | 29 | ## Usage 30 | 31 | Use provided `CascadeDeletes` trait in your model and define relation to be deleted in cascade. Related models will be deleted automatically and appropriately, that is either `hard` or `soft` deleted, depending on the related model settings and delete method used: 32 | 33 | #### tldr; 34 | 35 | 1. `$model->delete()` & `$query->delete()` w/o soft deletes 36 | 2. when using soft deletes ensure traits order: `use SoftDeletes, CascadeDeletes` 37 | 3. `$model->delete()` & `$query->delete()` with soft deletes 38 | 4. `$model->forceDelete()` **BUT `$query->forceDelete()` will not work** 39 | 40 | #### simple: 41 | 42 | ```php 43 | >> DB::enableQueryLog() 63 | => null 64 | >>> App\Product::find(200)->delete() 65 | => true 66 | >>> DB::getQueryLog() 67 | => [ 68 | [ 69 | "query" => "select * from `products` where `products`.`id` = ? limit 1", 70 | "bindings" => [200], 71 | ], 72 | [ 73 | "query" => "delete from `products` where `id` = ?", 74 | "bindings" => [200], 75 | ], 76 | [ 77 | "query" => "delete from `product_types` where `product_types`.`product_id` = ? and `product_types`.`product_id` is not null", 78 | "bindings" => [200], 79 | ], 80 | [ 81 | "query" => "delete from `photos` where `photos`.`product_id` = ? and `photos`.`product_id` is not null", 82 | "bindings" => [200], 83 | ], 84 | ] 85 | 86 | ``` 87 | 88 | * `delete()` called on the eloquent query `Builder`: 89 | 90 | ```php 91 | >>> App\Product::whereIn('id', [202, 203])->delete() 92 | => 2 93 | >>> DB::getQueryLog() 94 | => [ 95 | [ 96 | "query" => "select * from `products` where `id` in (?, ?)", 97 | "bindings" => [202, 203], 98 | ], 99 | [ 100 | "query" => "delete from `product_types` where `product_types`.`product_id` in (?, ?)", 101 | "bindings" => [202, 203], 102 | ], 103 | [ 104 | "query" => "update `photos` set `deleted_at` = ?, `updated_at` = ? where `photos`.`product_id` in (?, ?) and `photos`.`deleted_at` is null", 105 | "bindings" => [ 106 | "2016-05-31 09:44:41", 107 | "2016-05-31 09:44:41", 108 | 202, 109 | 203, 110 | ], 111 | ], 112 | [ 113 | "query" => "delete from `products` where `id` in (?, ?)", 114 | "bindings" => [202, 203], 115 | ], 116 | ] 117 | 118 | ``` 119 | 120 | 121 | #### using with `SoftDeletes` 122 | 123 | **NOTE** order of using traits matters, so make sure you use `SoftDeletes` before `CascadeDeletes`. 124 | 125 | ```php 126 | >> App\Product::whereIn('id', [300, 301])->delete() 146 | => 2 147 | >>> DB::getQueryLog() 148 | => [ 149 | [ 150 | "query" => "select * from `products` where `id` in (?, ?) and `products`.`deleted_at` is null", 151 | "bindings" => [300, 301], 152 | ], 153 | [ 154 | "query" => "delete from `product_types` where `product_types`.`product_id` in (?, ?)", 155 | "bindings" => [300, 301], 156 | ], 157 | [ 158 | "query" => "update `photos` set `deleted_at` = ?, `updated_at` = ? where `photos`.`product_id` in (?, ?) and `photos`.`deleted_at` is null", 159 | "bindings" => [ 160 | "2016-05-31 09:52:30", 161 | "2016-05-31 09:52:30", 162 | 300, 163 | 301, 164 | ], 165 | ], 166 | [ 167 | "query" => "update `products` set `deleted_at` = ?, `updated_at` = ? where `id` in (?, ?) and `products`.`deleted_at` is null", 168 | "bindings" => [ 169 | "2016-05-31 09:52:30", 170 | "2016-05-31 09:52:30", 171 | 300, 172 | 301, 173 | ], 174 | ], 175 | ] 176 | 177 | ``` 178 | 179 | 180 | * cascade with `forceDelete()` called on the model will hard-delete all the relations (**NOTE** due to the current implementation of forceDelete in laravel core, it will not work on the Builder) 181 | 182 | ```php 183 | >>> App\Product::find(302)->forceDelete() 184 | => true 185 | >>> DB::getQueryLog() 186 | => [ 187 | [ 188 | "query" => "select * from `products` where `products`.`id` = ? and `products`.`deleted_at` is null limit 1", 189 | "bindings" => [302], 190 | ], 191 | [ 192 | "query" => "delete from `products` where `id` = ?", 193 | "bindings" => [302], 194 | ], 195 | [ 196 | "query" => "delete from `product_types` where `product_types`.`product_id` = ? and `product_types`.`product_id` is not null", 197 | "bindings" => [302], 198 | ], 199 | [ 200 | "query" => "delete from `photos` where `photos`.`product_id` = ? and `photos`.`product_id` is not null", 201 | "bindings" => [302], 202 | ], 203 | ] 204 | 205 | ``` 206 | 207 | ## TODO 208 | 209 | - [x] cascade `restoring` soft deleted models 210 | - [ ] detach m-m relations / delete related 211 | - [ ] add SET NULL and RESTRICT options (?) 212 | 213 | 214 | 215 | ## Contribution 216 | 217 | All contributions are welcome, PRs must be **PSR-2 compliant**. 218 | 219 | 220 | ## CHANGELOG 221 | 222 | #### v6 <- v5.x 223 | - Restoring now will cascade **only for children that were deleted along with the parent model**, not before. That is, if some of children models were soft deleted before the parent model got deleted, those children will not be restored when parent is being restored. That's the expected behavior. 224 | - The above requires that when calling restore on the query builder rather than single model (`$query->restore()` vs `$model->restore()`), it will run N queries, 1 for each restored model. 225 | --------------------------------------------------------------------------------