├── .gitignore ├── src ├── CascadesDeletesModel.php └── CascadesDeletes.php ├── tests ├── Models │ ├── Photo.php │ ├── Profile.php │ ├── SoftProfile.php │ ├── Comment.php │ ├── InvalidKid.php │ ├── SoftPost.php │ ├── PermanentPost.php │ ├── SoftUser.php │ ├── ExtendedUser.php │ ├── Post.php │ └── User.php ├── ModelTest.php ├── helpers.php ├── TestCase.php ├── TraitTest.php └── IntegrationTest.php ├── .scrutinizer.yml ├── phpcs.xml ├── phpunit.9.xml ├── phpunit.10.ignore.deprecations.xml ├── phpunit.xml ├── LICENSE.md ├── CONTRIBUTING.md ├── composer.json ├── .github └── workflows │ └── phpunit.yml ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | composer.lock -------------------------------------------------------------------------------- /src/CascadesDeletesModel.php: -------------------------------------------------------------------------------- 1 | morphTo(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/Models/Profile.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tests/Models/SoftProfile.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | build: 2 | nodes: 3 | analysis: 4 | environment: 5 | php: 8.3.11 6 | tests: 7 | override: 8 | - php-scrutinizer-run 9 | - phpcs-run 10 | 11 | filter: 12 | excluded_paths: [tests/*] 13 | 14 | tools: 15 | external_code_coverage: 16 | timeout: 5400 17 | runs: 12 18 | -------------------------------------------------------------------------------- /tests/Models/Comment.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 14 | } 15 | 16 | public function post() 17 | { 18 | return $this->belongsTo(Post::class); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/ModelTest.php: -------------------------------------------------------------------------------- 1 | assertContains(CascadesDeletes::class, class_uses_recursive(ExtendedUser::class)); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/Models/InvalidKid.php: -------------------------------------------------------------------------------- 1 | morphTo(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Base PSR-2 with a few modifications. 4 | 5 | src 6 | tests 7 | 8 | 9 | 10 | 11 | 12 | 13 | tests/* 14 | 15 | 16 | 17 | tests/* 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /tests/Models/SoftPost.php: -------------------------------------------------------------------------------- 1 | belongsTo(SoftUser::class); 16 | } 17 | 18 | public function childPosts() 19 | { 20 | return $this->hasMany(SoftPost::class, 'parent_id'); 21 | } 22 | 23 | public function parentPost() 24 | { 25 | return $this->belongsTo(SoftPost::class, 'parent_id'); 26 | } 27 | 28 | public function comments() 29 | { 30 | return $this->hasMany(Comment::class, 'post_id'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Models/PermanentPost.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 12 | } 13 | 14 | public function childPosts() 15 | { 16 | return $this->hasMany(PermanentPost::class, 'parent_id'); 17 | } 18 | 19 | public function parentPost() 20 | { 21 | return $this->belongsTo(PermanentPost::class, 'parent_id'); 22 | } 23 | 24 | public function comments() 25 | { 26 | return $this->hasMany(Comment::class, 'post_id'); 27 | } 28 | 29 | public function delete() 30 | { 31 | return false; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Models/SoftUser.php: -------------------------------------------------------------------------------- 1 | belongsToMany(SoftUser::class, 'friends', 'user_id', 'friend_id'); 16 | } 17 | 18 | public function posts() 19 | { 20 | return $this->hasMany(SoftPost::class, 'user_id'); 21 | } 22 | 23 | public function comments() 24 | { 25 | return $this->hasMany(Comment::class, 'user_id'); 26 | } 27 | 28 | public function profile() 29 | { 30 | return $this->hasOne(SoftProfile::class, 'user_id'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /phpunit.9.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | ./src 12 | 13 | 14 | ./src/CascadesDeletesModel.php 15 | 16 | 17 | 18 | 19 | ./tests/ 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/Models/ExtendedUser.php: -------------------------------------------------------------------------------- 1 | belongsToMany(User::class, 'friends', 'user_id', 'friend_id'); 16 | } 17 | 18 | public function posts() 19 | { 20 | return $this->hasMany(Post::class); 21 | } 22 | 23 | public function photos() 24 | { 25 | return $this->morphMany(Photo::class, 'imageable'); 26 | } 27 | 28 | public function comments() 29 | { 30 | return $this->hasMany(Comment::class); 31 | } 32 | 33 | public function profile() 34 | { 35 | return $this->hasOne(Profile::class); 36 | } 37 | 38 | public function permanentPosts() 39 | { 40 | return $this->hasMany(PermanentPost::class); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /phpunit.10.ignore.deprecations.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | ./src 18 | 19 | 20 | ./src/CascadesDeletesModel.php 21 | 22 | 23 | 24 | 25 | ./tests/ 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 17 | 18 | ./src 19 | 20 | 21 | ./src/CascadesDeletesModel.php 22 | 23 | 24 | 25 | 26 | ./tests/ 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/Models/Post.php: -------------------------------------------------------------------------------- 1 | belongsTo(User::class); 19 | } 20 | 21 | public function photos() 22 | { 23 | return $this->morphMany(Photo::class, 'imageable'); 24 | } 25 | 26 | public function childPosts() 27 | { 28 | return $this->hasMany(Post::class, 'parent_id'); 29 | } 30 | 31 | public function parentPost() 32 | { 33 | return $this->belongsTo(Post::class, 'parent_id'); 34 | } 35 | 36 | public function comments() 37 | { 38 | return $this->hasMany(Comment::class); 39 | } 40 | 41 | public function invalidKids() 42 | { 43 | return $this->morphMany(InvalidKid::class, 'invalidable'); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/Models/User.php: -------------------------------------------------------------------------------- 1 | belongsToMany(User::class, 'friends', 'user_id', 'friend_id'); 19 | } 20 | 21 | public function posts() 22 | { 23 | return $this->hasMany(Post::class); 24 | } 25 | 26 | public function photos() 27 | { 28 | return $this->morphMany(Photo::class, 'imageable'); 29 | } 30 | 31 | public function comments() 32 | { 33 | return $this->hasMany(Comment::class); 34 | } 35 | 36 | public function profile() 37 | { 38 | return $this->hasOne(Profile::class); 39 | } 40 | 41 | public function permanentPosts() 42 | { 43 | return $this->hasMany(PermanentPost::class); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Patrick Carlo-Hickman 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 | -------------------------------------------------------------------------------- /tests/helpers.php: -------------------------------------------------------------------------------- 1 | $class], class_parents($class)) as $class) { 20 | $results += trait_uses_recursive($class); 21 | } 22 | 23 | return array_unique($results); 24 | } 25 | } 26 | 27 | if (!function_exists('trait_uses_recursive')) { 28 | /** 29 | * Returns all traits used by a trait and its traits. 30 | * 31 | * @param string $trait 32 | * 33 | * @return array 34 | */ 35 | function trait_uses_recursive($trait) 36 | { 37 | $traits = class_uses($trait); 38 | 39 | foreach ($traits as $trait) { 40 | $traits += trait_uses_recursive($trait); 41 | } 42 | 43 | return $traits; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/shiftonelabs/laravel-cascade-deletes). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests (within reason). 13 | 14 | - **Document any change in behavior** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | **Happy coding**! 26 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shiftonelabs/laravel-cascade-deletes", 3 | "description": "Adds application level cascading deletes to Eloquent Models.", 4 | "keywords": ["laravel", "lumen", "eloquent", "model", "cascade", "deletes"], 5 | "homepage": "https://github.com/shiftonelabs/laravel-cascade-deletes", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Patrick Carlo-Hickman", 10 | "email": "patrick@shiftonelabs.com" 11 | } 12 | ], 13 | "support": { 14 | "issues": "https://github.com/shiftonelabs/laravel-cascade-deletes/issues", 15 | "source": "https://github.com/shiftonelabs/laravel-cascade-deletes" 16 | }, 17 | "require": { 18 | "php": ">=8.0.2", 19 | "illuminate/database": ">=9.0", 20 | "illuminate/events": ">=9.0" 21 | }, 22 | "require-dev": { 23 | "mockery/mockery": "~1.3", 24 | "phpunit/phpunit": "~9.3 || ~10.0", 25 | "shiftonelabs/codesniffer-standard": "0.*", 26 | "squizlabs/php_codesniffer": "3.*" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "ShiftOneLabs\\LaravelCascadeDeletes\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "ShiftOneLabs\\LaravelCascadeDeletes\\Tests\\": "tests/" 36 | }, 37 | "files": [ 38 | "tests/helpers.php" 39 | ] 40 | }, 41 | "config": { 42 | "sort-packages": true, 43 | "allow-plugins": { 44 | "kylekatarnls/update-helper": false 45 | } 46 | }, 47 | "minimum-stability": "stable" 48 | } 49 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | addConnection([ 30 | 'driver' => 'sqlite', 31 | 'database' => ':memory:', 32 | ]); 33 | 34 | $db->setEventDispatcher(new Dispatcher(new Container())); 35 | 36 | $db->bootEloquent(); 37 | $db->setAsGlobal(); 38 | 39 | // This is required for testing Model events. If this is not done, the 40 | // events will only fire on the first test. 41 | Model::clearBootedModels(); 42 | } 43 | 44 | /** 45 | * Get a schema builder instance. 46 | * 47 | * @return \Illuminate\Database\Schema\Builder 48 | */ 49 | protected function schema($connection = 'default') 50 | { 51 | return $this->connection($connection)->getSchemaBuilder(); 52 | } 53 | 54 | /** 55 | * Get a database connection instance. 56 | * 57 | * @return \Illuminate\Database\Connection 58 | */ 59 | protected function connection($connection = 'default') 60 | { 61 | return Model::getConnectionResolver()->connection($connection); 62 | } 63 | 64 | /** 65 | * Use reflection to set the value of a restricted (private/protected) 66 | * property on an object. 67 | * 68 | * @param object $object 69 | * @param string $property 70 | * @param mixed $value 71 | * 72 | * @return void 73 | */ 74 | protected function setRestrictedValue($object, $property, $value) 75 | { 76 | $reflectionProperty = new ReflectionProperty($object, $property); 77 | $reflectionProperty->setAccessible(true); 78 | 79 | if ($reflectionProperty->isStatic()) { 80 | $reflectionProperty->setValue($value); 81 | } else { 82 | $reflectionProperty->setValue($object, $value); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /.github/workflows/phpunit.yml: -------------------------------------------------------------------------------- 1 | name: Phpunit 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | phpcs: 7 | runs-on: ubuntu-latest 8 | 9 | name: phpcs - PHP 8.4 10 | 11 | steps: 12 | - name: Checkout repo 13 | uses: actions/checkout@v3 14 | 15 | # Setup the PHP version to use. 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: '8.4' 20 | 21 | # Dependencies needed for the shiftonelabs/codesniffer-standard package. 22 | - name: Install dependencies 23 | run: composer update --prefer-dist --no-interaction 24 | 25 | # Run the phpcs tool. 26 | - name: Run phpcs 27 | run: ./vendor/bin/phpcs 28 | 29 | tests: 30 | runs-on: ${{ matrix.os }} 31 | strategy: 32 | # Turn off fail-fast so that all jobs will run even when one fails, 33 | # and the build will still get marked as failed. 34 | fail-fast: false 35 | 36 | matrix: 37 | os: [ubuntu-latest] 38 | php: ['8.0', '8.1', '8.2', '8.3', '8.4'] 39 | laravel: ['9.*', '10.*', '11.*'] 40 | exclude: 41 | - php: '8.0' 42 | laravel: '10.*' 43 | - php: '8.0' 44 | laravel: '11.*' 45 | - php: '8.1' 46 | laravel: '11.*' 47 | 48 | name: tests - PHP ${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.os }} 49 | 50 | steps: 51 | - name: Checkout repo 52 | uses: actions/checkout@v3 53 | with: 54 | # We need more than 1 commit to prevent the "Failed to retrieve 55 | # commit parents" error from the ocular code coverage upload. 56 | fetch-depth: 5 57 | 58 | # Setup the PHP version to use for the test and include xdebug to generate the code coverage file. 59 | - name: Setup PHP 60 | uses: shivammathur/setup-php@v2 61 | with: 62 | php-version: ${{ matrix.php }} 63 | coverage: xdebug 64 | 65 | # Setup the required packages for the version being tested and install the packages 66 | - name: Install dependencies 67 | run: | 68 | COMPOSER_MEMORY_LIMIT=-1 composer require "illuminate/database:${{ matrix.laravel }}" --no-update 69 | composer update --prefer-dist --no-interaction 70 | 71 | # Run the unit tests and generate the code coverage file. 72 | - name: Run phpunit tests 73 | run: | 74 | PHPUNIT_CONFIG="" 75 | ( [[ -z "${PHPUNIT_CONFIG}" ]] && [[ "${{ matrix.php }}" == "8.0" ]] ) && PHPUNIT_CONFIG="--configuration phpunit.9.xml" 76 | ( [[ -z "${PHPUNIT_CONFIG}" ]] && [[ "${{ matrix.php }}" == "8.4" ]] && ( [[ "${{ matrix.laravel }}" == "9.*" ]] || [[ "${{ matrix.laravel }}" == "10.*" ]] ) ) && PHPUNIT_CONFIG="--configuration phpunit.10.ignore.deprecations.xml" 77 | ./vendor/bin/phpunit ${PHPUNIT_CONFIG} --coverage-clover ./clover.xml 78 | 79 | # Send the code coverage file regardless of the tests passing or failing. 80 | - name: Send coverage 81 | if: success() || failure() 82 | run: | 83 | composer global require scrutinizer/ocular 84 | ~/.composer/vendor/bin/ocular code-coverage:upload --format=php-clover ./clover.xml 85 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [2.0.2] - 2024-12-14 10 | ### Added 11 | - Added new phpunit config for older PHP version. 12 | - Added new phpunit config for incompatible PHP/Laravel versions. 13 | 14 | ### Changed 15 | - Updated phpunit config to latest version. 16 | - Updated phpunit config to ensure tests fail on warnings, notices, and deprecations. 17 | - Updated Github Actions to use different phpunit configs. 18 | - Updated CI configs to add support for PHP 8.4. 19 | 20 | ### Fixed 21 | - Fixed deprecation notice in PHP 8.4. ([#12](https://github.com/shiftonelabs/laravel-cascade-deletes/pull/12)) 22 | 23 | ## [2.0.1] - 2024-09-22 24 | ### Changed 25 | - Updated CI configs to add support for Laravel 11 and PHP 8.3. 26 | - Updated readme with new version information. 27 | 28 | ### Fixed 29 | - Fixed `morphMany()` relationship typo in example code in readme. ([#9](https://github.com/shiftonelabs/laravel-cascade-deletes/pull/9)) 30 | 31 | ## [2.0.0] - 2023-03-27 32 | ### Removed 33 | - Removed support for Laravel 4.1 - Laravel 8.x. These are all EOL and will never change, so version 1.0.3 will always work for them. 34 | - Removed support for PHP 5.5 - PHP 7.4. These are all EOL and will never change, so version 1.0.3 will always work for them. 35 | 36 | ### Changed 37 | - Updated package dependencies to support new minimum Laravel and PHP versions. 38 | - Updated CI configs to support new minimum Laravel and PHP versions. 39 | - Updated the README to reflect the new version changes. 40 | 41 | ## [1.0.3] - 2023-03-24 42 | ### Changed 43 | - Converted CI from Travis CI to Github Actions. 44 | - Updated CI config to stop running tests in Scrutinizer. 45 | 46 | ## [1.0.2] - 2023-03-23 47 | ### Changed 48 | - Updated readme to make copying the composer command easier. ([#8](https://github.com/shiftonelabs/laravel-cascade-deletes/pull/8)) 49 | - Updated readme with new version information. 50 | - Updated tense in changelog. 51 | 52 | ## [1.0.1] - 2020-04-02 53 | ### Added 54 | - New changelog. 55 | 56 | ### Changed 57 | - Updated tests to work with all supported Laravel versions. 58 | - Updated CI configs for increased test coverage across versions. 59 | - Small code cleanup items across the code base. 60 | - Updated readme with new version information. 61 | - Sort the packages in composer.json. 62 | 63 | ### Fixed 64 | - Fix count of soft deleted records to work with changes in Laravel >= 5.5. 65 | 66 | ## 1.0.0 - 2016-12-08 67 | ### Added 68 | - Initial release! 69 | 70 | [Unreleased]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/2.0.2...HEAD 71 | [2.0.2]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/2.0.1...2.0.2 72 | [2.0.1]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/2.0.0...2.0.1 73 | [2.0.0]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/1.0.3...2.0.0 74 | [1.0.3]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/1.0.2...1.0.3 75 | [1.0.2]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/1.0.1...1.0.2 76 | [1.0.1]: https://github.com/shiftonelabs/laravel-cascade-deletes/compare/1.0.0...1.0.1 77 | -------------------------------------------------------------------------------- /tests/TraitTest.php: -------------------------------------------------------------------------------- 1 | assertNotEmpty($user->getCascadeDeletes()); 17 | } 18 | 19 | public function testCanSetCascadeDeletesProperty() 20 | { 21 | $user = new User(); 22 | $newDeletes = ['new', 'deletes']; 23 | 24 | $user->setCascadeDeletes($newDeletes); 25 | 26 | $this->assertEquals($newDeletes, $user->getCascadeDeletes()); 27 | } 28 | 29 | public function testGetRelationNamesReturnsArrayFromArray() 30 | { 31 | $user = new User(); 32 | 33 | $names = $user->getCascadeDeletesRelationNames(); 34 | 35 | $this->assertIsArray($names); 36 | } 37 | 38 | public function testGetRelationNamesReturnsPopulatedArrayFromArray() 39 | { 40 | $user = new User(); 41 | 42 | $names = $user->getCascadeDeletesRelationNames(); 43 | 44 | $this->assertNotEmpty($names); 45 | } 46 | 47 | public function testGetRelationNamesReturnsArrayFromString() 48 | { 49 | $user = new User(); 50 | $user->setCascadeDeletes('string_value'); 51 | 52 | $names = $user->getCascadeDeletesRelationNames(); 53 | 54 | $this->assertIsArray($names); 55 | } 56 | 57 | public function testGetRelationNamesReturnsPopulatedArrayFromString() 58 | { 59 | $user = new User(); 60 | $user->setCascadeDeletes('string_value'); 61 | 62 | $names = $user->getCascadeDeletesRelationNames(); 63 | 64 | $this->assertNotEmpty($names); 65 | } 66 | 67 | public function testGetRelationNamesReturnsArrayFromNonEmptyValue() 68 | { 69 | $user = new User(); 70 | $user->setCascadeDeletes(1234); 71 | 72 | $names = $user->getCascadeDeletesRelationNames(); 73 | 74 | $this->assertIsArray($names); 75 | } 76 | 77 | public function testGetRelationNamesReturnsPopulatedArrayFromNonEmptyValue() 78 | { 79 | $user = new User(); 80 | $user->setCascadeDeletes(1234); 81 | 82 | $names = $user->getCascadeDeletesRelationNames(); 83 | 84 | $this->assertNotEmpty($names); 85 | } 86 | 87 | public function testGetRelationNamesReturnsArrayFromEmptyValue() 88 | { 89 | $user = new User(); 90 | $user->setCascadeDeletes(null); 91 | 92 | $names = $user->getCascadeDeletesRelationNames(); 93 | 94 | $this->assertIsArray($names); 95 | } 96 | 97 | public function testGetRelationNamesReturnsEmptyArrayFromEmptyValue() 98 | { 99 | $user = new User(); 100 | $user->setCascadeDeletes(null); 101 | 102 | $names = $user->getCascadeDeletesRelationNames(); 103 | 104 | $this->assertEmpty($names); 105 | } 106 | 107 | public function testGetRelationsReturnsRelationObjectsForValidNames() 108 | { 109 | $user = new User(); 110 | $user->setCascadeDeletes(['friends', 'posts', 'photos']); 111 | $expected = [ 112 | 'friends' => $user->friends(), 113 | 'posts' => $user->posts(), 114 | 'photos' => $user->photos(), 115 | ]; 116 | 117 | $relations = $user->getCascadeDeletesRelations(); 118 | 119 | $this->assertEquals($expected, $relations); 120 | } 121 | 122 | public function testGetRelationsReturnsNullForInvalidNames() 123 | { 124 | $user = new User(); 125 | $user->setCascadeDeletes(['friends', 'asdf', 1234]); 126 | $expected = [ 127 | 'friends' => $user->friends(), 128 | 'asdf' => null, 129 | 1234 => null, 130 | ]; 131 | 132 | $relations = $user->getCascadeDeletesRelations(); 133 | 134 | $this->assertEquals($expected, $relations); 135 | } 136 | 137 | public function testGetRelationsExcludesEmptyNames() 138 | { 139 | $user = new User(); 140 | $user->setCascadeDeletes(['friends', '', 0, null, 'posts']); 141 | $expected = [ 142 | 'friends' => $user->friends(), 143 | 'posts' => $user->posts(), 144 | ]; 145 | 146 | $relations = $user->getCascadeDeletesRelations(); 147 | 148 | $this->assertEquals($expected, $relations); 149 | } 150 | 151 | public function testGetInvalidRelationsReturnsInvalidNames() 152 | { 153 | $user = new User(); 154 | $user->setCascadeDeletes(['friends', 'asdf', 1234]); 155 | $expected = ['asdf', 1234]; 156 | 157 | $names = $user->getInvalidCascadeDeletesRelations(); 158 | 159 | $this->assertEquals($expected, $names); 160 | } 161 | 162 | public function testGetInvalidRelationsExcludesEmptyNames() 163 | { 164 | $user = new User(); 165 | $user->setCascadeDeletes(['asdf', '', 0, null, 1234]); 166 | $expected = ['asdf', 1234]; 167 | 168 | $names = $user->getInvalidCascadeDeletesRelations(); 169 | 170 | $this->assertEquals($expected, $names); 171 | } 172 | 173 | public function testIsForceDeletingReturnsTrueWhenForceDeleting() 174 | { 175 | $user = new SoftUser(); 176 | 177 | $this->setRestrictedValue($user, 'forceDeleting', true); 178 | 179 | $this->assertTrue($user->isCascadeDeletesForceDeleting()); 180 | } 181 | 182 | public function testIsForceDeletingReturnsFalseWhenNotForceDeleting() 183 | { 184 | $user = new SoftUser(); 185 | 186 | $this->assertFalse($user->isCascadeDeletesForceDeleting()); 187 | } 188 | 189 | public function testGetCascadeDeletesRelationQueryReturnsRelation() 190 | { 191 | $user = new User(); 192 | 193 | $query = $user->getCascadeDeletesRelationQuery($user->getCascadeDeletesRelationNames()[0]); 194 | 195 | $this->assertInstanceOf(Relation::class, $query); 196 | } 197 | 198 | public function testCascadeDeletesRelationQueryExcludesTrashedWhenNotForceDeleting() 199 | { 200 | $user = new SoftUser(); 201 | 202 | $query = $user->getCascadeDeletesRelationQuery($user->getCascadeDeletesRelationNames()[0])->getQuery(); 203 | 204 | $this->assertNotContains(SoftDeletingScope::class, $query->removedScopes()); 205 | } 206 | 207 | public function testCascadeDeletesRelationQueryIncludesTrashedWhenForceDeleting() 208 | { 209 | $user = new SoftUser(); 210 | 211 | $this->setRestrictedValue($user, 'forceDeleting', true); 212 | 213 | $query = $user->getCascadeDeletesRelationQuery($user->getCascadeDeletesRelationNames()[0])->getQuery(); 214 | 215 | $this->assertContains(SoftDeletingScope::class, $query->removedScopes()); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/CascadesDeletes.php: -------------------------------------------------------------------------------- 1 | getConnectionResolver()->transaction(function () use ($model) { 27 | 28 | $relations = $model->getCascadeDeletesRelations(); 29 | 30 | if ($invalidRelations = $model->getInvalidCascadeDeletesRelations($relations)) { 31 | throw new LogicException(sprintf('[%s]: invalid relationship(s) for cascading deletes. Relationship method(s) [%s] must return an object of type Illuminate\Database\Eloquent\Relations\Relation.', static::class, implode(', ', $invalidRelations))); 32 | } 33 | 34 | $deleteMethod = $model->isCascadeDeletesForceDeleting() ? 'forceDelete' : 'delete'; 35 | 36 | foreach ($relations as $relationName => $relation) { 37 | $expected = 0; 38 | $deleted = 0; 39 | 40 | if ($relation instanceof BelongsToMany) { 41 | // Process the many-to-many relationships on the model. 42 | // These relationships should not delete the related 43 | // record, but should just detach from each other. 44 | 45 | $expected = $model->getCascadeDeletesRelationQuery($relationName)->count(); 46 | 47 | $deleted = $model->getCascadeDeletesRelationQuery($relationName)->detach(); 48 | } elseif ($relation instanceof HasOneOrMany) { 49 | // Process the one-to-one and one-to-many relationships 50 | // on the model. These relationships should actually 51 | // delete the related records from the database. 52 | 53 | $children = $model->getCascadeDeletesRelationQuery($relationName)->get(); 54 | 55 | // To protect against potential relationship defaults, 56 | // filter out any children that may not actually be 57 | // Model instances, or that don't actually exist. 58 | $children = $children->filter(function ($child) { 59 | return $child instanceof Model && $child->exists; 60 | })->all(); 61 | 62 | $expected = count($children); 63 | 64 | foreach ($children as $child) { 65 | // Delete the record using the proper method. 66 | $deleted += $child->$deleteMethod(); 67 | } 68 | } else { 69 | // Not all relationship types make sense for cascading. As an 70 | // example, for a BelongsTo relationship, it does not make 71 | // sense to delete the parent when the child is deleted. 72 | throw new LogicException(sprintf('[%s]: error occurred deleting [%s]. Relation type [%s] not handled.', static::class, $relationName, get_class($relation))); 73 | } 74 | 75 | if ($deleted < $expected) { 76 | throw new LogicException(sprintf('[%s]: error occurred deleting [%s]. Only deleted [%d] out of [%d] records.', static::class, $relationName, $deleted, $expected)); 77 | } 78 | } 79 | }); 80 | }); 81 | } 82 | 83 | /** 84 | * Get the value of the cascadeDeletes attribute, if it exists. 85 | * 86 | * @return mixed 87 | */ 88 | public function getCascadeDeletes() 89 | { 90 | return property_exists($this, 'cascadeDeletes') ? $this->cascadeDeletes : []; 91 | } 92 | 93 | /** 94 | * Set the cascadeDeletes attribute. 95 | * 96 | * @param mixed $cascadeDeletes 97 | * 98 | * @return void 99 | */ 100 | public function setCascadeDeletes($cascadeDeletes) 101 | { 102 | $this->cascadeDeletes = $cascadeDeletes; 103 | } 104 | 105 | /** 106 | * Get an array of cascading relation names. 107 | * 108 | * @return array 109 | */ 110 | public function getCascadeDeletesRelationNames() 111 | { 112 | $deletes = $this->getCascadeDeletes(); 113 | 114 | return array_filter(is_array($deletes) ? $deletes : [$deletes]); 115 | } 116 | 117 | /** 118 | * Get an array of the cascading relation names mapped to their relation types. 119 | * 120 | * @return array 121 | */ 122 | public function getCascadeDeletesRelations() 123 | { 124 | $names = $this->getCascadeDeletesRelationNames(); 125 | 126 | return array_combine($names, array_map(function ($name) { 127 | $relation = method_exists($this, $name) ? $this->$name() : null; 128 | 129 | return $relation instanceof Relation ? $relation : null; 130 | }, $names)); 131 | } 132 | 133 | /** 134 | * Get an array of the invalid cascading relation names. 135 | * 136 | * @param array|null $relations 137 | * 138 | * @return array 139 | */ 140 | public function getInvalidCascadeDeletesRelations(?array $relations = null) 141 | { 142 | // This will get the array keys for any item in the array where the 143 | // value is null. If the value is null, that means that the name 144 | // of the relation provided does not return a Relation object. 145 | return array_keys($relations ?: $this->getCascadeDeletesRelations(), null); 146 | } 147 | 148 | /** 149 | * Get the relationship query to use for the specified relation. 150 | * 151 | * @param string $relation 152 | * 153 | * @return \Illuminate\Database\Eloquent\Relations\Relation 154 | */ 155 | public function getCascadeDeletesRelationQuery($relation) 156 | { 157 | $query = $this->$relation(); 158 | 159 | // If this is a force delete and the related model is using soft deletes, 160 | // we need to use the withTrashed() scope on the relationship query to 161 | // ensure all related records, plus soft deleted, are force deleted. 162 | if ($this->isCascadeDeletesForceDeleting() && !is_null($query->getMacro('withTrashed'))) { 163 | $query = $query->withTrashed(); 164 | } 165 | 166 | return $query; 167 | } 168 | 169 | /** 170 | * Check if this cascading delete is a force delete. 171 | * 172 | * @return boolean 173 | */ 174 | public function isCascadeDeletesForceDeleting() 175 | { 176 | return property_exists($this, 'forceDeleting') && $this->forceDeleting; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # laravel-cascade-deletes 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.txt) 5 | [![Build Status][ico-github-actions]][link-github-actions] 6 | [![Coverage Status][ico-scrutinizer]][link-scrutinizer] 7 | [![Quality Score][ico-code-quality]][link-code-quality] 8 | [![Total Downloads][ico-downloads]][link-downloads] 9 | 10 | This Laravel/Lumen package provides application level cascading deletes for the Laravel's Eloquent ORM. When referential integrity is not, or cannot be, enforced at the data storage level, this package makes it easy to set this up at the application level. 11 | 12 | For example, if you are using `SoftDeletes`, or are using polymorphic relationships, these are situations where foreign keys in the database cannot enforce referential integrity, and the application needs to step in. This package can help. 13 | 14 | ## Versions 15 | 16 | This package has been tested on Laravel 4.1 through Laravel 11.x, though it may continue to work on later versions as they are released. This section will be updated to reflect the versions on which the package has actually been tested. 17 | 18 | This readme has been updated to show information for the most currently supported versions (9.x - 11.x). For Laravel 4.1 through Laravel 8.x, view the 1.x branch. 19 | 20 | ## Install 21 | 22 | Via Composer 23 | 24 | ``` bash 25 | composer require shiftonelabs/laravel-cascade-deletes 26 | ``` 27 | 28 | ## Usage 29 | 30 | Enabling cascading deletes can be done two ways. Either: 31 | - update your `Model` to extend the `\ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletesModel`, or 32 | - update your `Model` to use the `\ShiftOneLabs\LaravelCascadeDeletes\CascadesDeletes` trait. 33 | 34 | Once that is done, define the `$cascadeDeletes` property on the `Model`. The `$cascadeDeletes` property should be set to an array of the relationships that should be deleted when a parent record is deleted. 35 | 36 | Now, when a parent record is deleted, the defined child records will also be deleted. Furthermore, in the case where a child record also has cascading deletes defined, the delete will cascade down and delete the related records of the child, as well. This will continue on until all children, grandchildren, great grandchildren, etc. are deleted. 37 | 38 | Additionally, all cascading deletes are performed within a transaction. This makes the delete an "all or nothing" event. If, for any reason, a child record could not be deleted, the transaction will rollback and no records will be deleted at all. The `Exception` that caused the child not to be deleted will bubble up to where the `delete()` originally started, and will need to be caught and handled.** 39 | 40 | #### Code Example 41 | 42 | User Model: 43 | 44 | ``` php 45 | hasMany(Post::class); 60 | } 61 | 62 | public function profile() 63 | { 64 | return $this->hasOne(Profile::class); 65 | } 66 | 67 | public function type() 68 | { 69 | return $this->belongsTo(Type::class); 70 | } 71 | } 72 | ``` 73 | 74 | Profile Model: 75 | 76 | ``` php 77 | belongsTo(User::class); 92 | } 93 | 94 | public function addresses() 95 | { 96 | return $this->morphMany(Address::class, 'addressable'); 97 | } 98 | } 99 | ``` 100 | 101 | In the example above, the `CascadesDeletes` trait has been added to the `User` model to enable cascading deletes. Since the user is considered a parent of posts and profiles, these relationships have been added to the `$cascadeDeletes` property. Additionally, the `Profile` model has been set up to delete its related address records. 102 | 103 | Given this setup, when a user record is deleted, all related posts and profile records will be deleted. The delete will also cascade down into the profile record, and it will delete all the addresses related to the profile, as well. 104 | 105 | If any one of the posts, profiles, or addresses fails to be deleted, the transaction will roll back and no records will be deleted, including the original user record.** 106 | 107 | ** Transaction rollback will only occur if the database being used actually supports transactions. Most do, but some do not. For example, the MySQL `InnoDB` engine supports transactions, but the MySQL `MyISAM` engine does not. 108 | 109 | #### SoftDeletes 110 | 111 | This package also works with Models that are setup with `SoftDeletes`. 112 | 113 | When using `SoftDeletes`, the delete method being used will cascade to the rest of the deletes, as well. That is, if you `delete()` a record, all the child records will also use `delete()`; if you `forceDelete()` a record, all the child records will also use `forceDelete()`. 114 | 115 | The deletes will also cross the boundary between soft deletes and hard deletes. In the code example above, the the `User` record was setup to soft delete, but the `Profile` record was not, then when a user is deleted, the `User` record would be soft deleted, but the child `Profile` record would be hard deleted, and vice versa. 116 | 117 | ## Notes 118 | 119 | - The functionality in this package is provided through the `deleting` event on the `Model`. Therefore, in order for the cascading deletes to work, `delete()` must be called on a model instance. Deletes will not cascade if a delete is performed through the query builder. For example, `App\User::where('active', 0)->delete();` will only delete those user records, and will not perform any cascading deletes, since the `delete()` was performed on the query builder and not on a model instance. 120 | 121 | - Do not add a `BelongsTo` relationship to the `$cascadeDeletes` array. This will cause a `LogicException`, and no records will be deleted. This is done as a `BelongsTo` typically represents a child record, and it usually does not make sense to delete a parent record from a child record. 122 | 123 | ## Contributing 124 | 125 | Contributions are welcome. Please see [CONTRIBUTING](CONTRIBUTING.md) for details. 126 | 127 | ## Security 128 | 129 | If you discover any security related issues, please email patrick@shiftonelabs.com instead of using the issue tracker. 130 | 131 | ## Credits 132 | 133 | - [Patrick Carlo-Hickman][link-author] 134 | - [All Contributors][link-contributors] 135 | 136 | ## License 137 | 138 | The MIT License (MIT). Please see [License File](LICENSE.txt) for more information. 139 | 140 | [ico-version]: https://img.shields.io/packagist/v/shiftonelabs/laravel-cascade-deletes.svg?style=flat-square 141 | [ico-license]: https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square 142 | [ico-github-actions]: https://img.shields.io/github/actions/workflow/status/shiftonelabs/laravel-cascade-deletes/.github/workflows/phpunit.yml?style=flat-square 143 | [ico-scrutinizer]: https://img.shields.io/scrutinizer/coverage/g/shiftonelabs/laravel-cascade-deletes.svg?style=flat-square 144 | [ico-code-quality]: https://img.shields.io/scrutinizer/g/shiftonelabs/laravel-cascade-deletes.svg?style=flat-square 145 | [ico-downloads]: https://img.shields.io/packagist/dt/shiftonelabs/laravel-cascade-deletes.svg?style=flat-square 146 | 147 | [link-packagist]: https://packagist.org/packages/shiftonelabs/laravel-cascade-deletes 148 | [link-github-actions]: https://github.com/shiftonelabs/laravel-cascade-deletes/actions 149 | [link-scrutinizer]: https://scrutinizer-ci.com/g/shiftonelabs/laravel-cascade-deletes/code-structure 150 | [link-code-quality]: https://scrutinizer-ci.com/g/shiftonelabs/laravel-cascade-deletes 151 | [link-downloads]: https://packagist.org/packages/shiftonelabs/laravel-cascade-deletes 152 | [link-author]: https://github.com/patrickcarlohickman 153 | [link-contributors]: ../../contributors 154 | -------------------------------------------------------------------------------- /tests/IntegrationTest.php: -------------------------------------------------------------------------------- 1 | setUpDatabaseConnection(); 26 | 27 | $this->createSchema(); 28 | } 29 | 30 | protected function createSchema() 31 | { 32 | $this->schema()->create('users', function ($table) { 33 | $table->increments('id'); 34 | $table->timestamps(); 35 | $table->softDeletes(); 36 | $table->string('name')->nullable(); 37 | $table->string('email'); 38 | }); 39 | 40 | $this->schema()->create('friends', function ($table) { 41 | $table->integer('user_id'); 42 | $table->integer('friend_id'); 43 | }); 44 | 45 | $this->schema()->create('posts', function ($table) { 46 | $table->increments('id'); 47 | $table->timestamps(); 48 | $table->softDeletes(); 49 | $table->integer('user_id'); 50 | $table->integer('parent_id')->nullable(); 51 | $table->string('name'); 52 | }); 53 | 54 | $this->schema()->create('comments', function ($table) { 55 | $table->increments('id'); 56 | $table->timestamps(); 57 | $table->softDeletes(); 58 | $table->integer('post_id'); 59 | $table->integer('user_id'); 60 | $table->string('comment'); 61 | }); 62 | 63 | $this->schema()->create('photos', function ($table) { 64 | $table->increments('id'); 65 | $table->timestamps(); 66 | $table->softDeletes(); 67 | $table->morphs('imageable'); 68 | $table->string('name'); 69 | }); 70 | 71 | $this->schema()->create('invalid_kids', function ($table) { 72 | $table->increments('id'); 73 | $table->timestamps(); 74 | $table->softDeletes(); 75 | $table->morphs('invalidable'); 76 | $table->string('name'); 77 | }); 78 | 79 | $this->schema()->create('profiles', function ($table) { 80 | $table->increments('id'); 81 | $table->timestamps(); 82 | $table->softDeletes(); 83 | $table->integer('user_id'); 84 | }); 85 | } 86 | 87 | /** 88 | * Tear down run after each test. 89 | * 90 | * @after 91 | */ 92 | public function afterTearDown() 93 | { 94 | $this->schema()->dropIfExists('users'); 95 | $this->schema()->dropIfExists('friends'); 96 | $this->schema()->dropIfExists('posts'); 97 | $this->schema()->dropIfExists('comments'); 98 | $this->schema()->dropIfExists('photos'); 99 | $this->schema()->dropIfExists('invalid_kids'); 100 | $this->schema()->dropIfExists('profiles'); 101 | } 102 | 103 | public function testInvalidRelationshipThrowsException() 104 | { 105 | $this->expectException(LogicException::class); 106 | $this->expectExceptionMessage('invalid relationship(s) for cascading deletes'); 107 | 108 | $user = User::create(['email' => 'user@example.com']); 109 | $user->setCascadeDeletes(['non_existing_relation']); 110 | 111 | $user->delete(); 112 | } 113 | 114 | public function testInvalidRelationshipTypeThrowsException() 115 | { 116 | $this->expectException(LogicException::class); 117 | $this->expectExceptionMessageMatches('/Relation type .* not handled/'); 118 | 119 | $post = Post::create(['user_id' => 0, 'name' => 'First Post']); 120 | $post->setCascadeDeletes(['user']); 121 | 122 | $post->delete(); 123 | } 124 | 125 | public function testNotAllRecordsDeletedThrowsException() 126 | { 127 | $this->expectException(LogicException::class); 128 | $this->expectExceptionMessage('Only deleted [0] out of [1] records'); 129 | 130 | $user = User::create(['email' => 'user@example.com']); 131 | $post = $user->permanentPosts()->create(['name' => 'First Post']); 132 | $user->setCascadeDeletes(['permanentPosts']); 133 | 134 | $user->delete(); 135 | } 136 | 137 | public function testDeletesCascadeFirstLevel() 138 | { 139 | $user = User::create(['email' => 'user@example.com']); 140 | $user->photos()->create(['name' => 'Avatar 1']); 141 | $user->photos()->create(['name' => 'Avatar 2']); 142 | $friend = $user->friends()->create(['email' => 'friend@example.com']); 143 | $post = $user->posts()->create(['name' => 'First Post']); 144 | $user->comments()->create(['post_id' => $post->id, 'comment' => 'First Comment']); 145 | $user->comments()->create(['post_id' => $post->id, 'comment' => 'Second Comment']); 146 | 147 | $this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count()); 148 | 149 | $user->delete(); 150 | 151 | $this->assertEquals(1, User::count()); 152 | $this->assertEquals(1, User::count() + Photo::count() + Post::count() + Comment::count()); 153 | } 154 | 155 | public function testDeletesCascadeSecondLevel() 156 | { 157 | $user = User::create(['email' => 'user@example.com']); 158 | $post = $user->posts()->create(['name' => 'First Post']); 159 | $post->photos()->create(['name' => 'Hero 1']); 160 | $post->photos()->create(['name' => 'Hero 2']); 161 | $childPost = $post->childPosts()->create(['user_id' => $user->id, 'name' => 'First Child Post']); 162 | $post->comments()->create(['user_id' => 0, 'comment' => 'First Comment']); 163 | $post->comments()->create(['user_id' => 0, 'comment' => 'Second Comment']); 164 | 165 | $this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count()); 166 | 167 | $user->delete(); 168 | 169 | $this->assertEquals(0, User::count() + Photo::count() + Post::count() + Comment::count()); 170 | } 171 | 172 | public function testDeletesCascadeLowerLevels() 173 | { 174 | $user = User::create(['email' => 'user@example.com']); 175 | $post = $user->posts()->create(['name' => 'First Post']); 176 | $childPost = $post->childPosts()->create(['user_id' => 0, 'name' => 'First Child Post']); 177 | $grandchildPost = $childPost->childPosts()->create(['user_id' => 0, 'name' => 'First Grandchild Post']); 178 | $greatGrandchildPost = $grandchildPost->childPosts()->create(['user_id' => 0, 'name' => 'First Great Grandchild Post']); 179 | 180 | $this->assertEquals(5, User::count() + Post::count()); 181 | 182 | $user->delete(); 183 | 184 | $this->assertEquals(0, User::count() + Post::count()); 185 | } 186 | 187 | public function testEntireTransactionIsRolledBack() 188 | { 189 | $user = User::create(['email' => 'user@example.com']); 190 | $post = $user->posts()->create(['name' => 'First Post']); 191 | $post->photos()->create(['name' => 'Hero 1']); 192 | $post->photos()->create(['name' => 'Hero 2']); 193 | $invalidKid = $post->invalidKids()->create(['name' => 'First Invalid Kid']); 194 | $post->comments()->create(['user_id' => 0, 'comment' => 'First Comment']); 195 | $post->comments()->create(['user_id' => 0, 'comment' => 'Second Comment']); 196 | 197 | $this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count()); 198 | 199 | try { 200 | $exceptionThrown = false; 201 | 202 | $user->delete(); 203 | } catch (LogicException $e) { 204 | $exceptionThrown = true; 205 | } 206 | 207 | $this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count()); 208 | $this->assertTrue($exceptionThrown); 209 | } 210 | 211 | public function testDeletesHiddenRelatedRecords() 212 | { 213 | $user = User::create(['email' => 'user@example.com']); 214 | $user->profile()->create([]); 215 | $user->profile()->create([]); 216 | 217 | $this->assertEquals(3, User::count() + Profile::count()); 218 | 219 | $user->delete(); 220 | 221 | $this->assertEquals(0, User::count() + Profile::count()); 222 | } 223 | 224 | public function testDeletesOnlyRelatedRecords() 225 | { 226 | $user = User::create(['email' => 'user@example.com']); 227 | $user->profile()->create([]); 228 | $user->profile()->create([]); 229 | $user2 = User::create(['email' => 'user2@example.com']); 230 | $user2->profile()->create([]); 231 | 232 | $this->assertEquals(5, User::count() + Profile::count()); 233 | 234 | $user->delete(); 235 | 236 | $this->assertEquals(2, User::count() + Profile::count()); 237 | } 238 | 239 | public function testSoftDeletesCascade() 240 | { 241 | $user = SoftUser::create(['email' => 'user@example.com']); 242 | $user->photos()->create(['name' => 'Avatar 1']); 243 | $user->photos()->create(['name' => 'Avatar 2']); 244 | $friend = $user->friends()->create(['email' => 'friend@example.com']); 245 | $post = $user->posts()->create(['name' => 'First Post']); 246 | $user->comments()->create(['post_id' => $post->id, 'comment' => 'First Comment']); 247 | $user->comments()->create(['post_id' => $post->id, 'comment' => 'Second Comment']); 248 | 249 | $this->assertEquals(7, SoftUser::count() + SoftPost::count() + Photo::count() + Comment::count()); 250 | 251 | $user->delete(); 252 | 253 | $this->assertEquals(1, SoftUser::count()); 254 | $this->assertEquals(0, SoftPost::count()); 255 | $this->assertEquals(1, SoftUser::count() + SoftPost::count() + Photo::count() + Comment::count()); 256 | 257 | $this->assertEquals(2, SoftUser::withTrashed()->count()); 258 | $this->assertEquals(1, SoftPost::withTrashed()->count()); 259 | $this->assertEquals(3, SoftUser::withTrashed()->count() + SoftPost::withTrashed()->count() + Photo::count() + Comment::count()); 260 | } 261 | 262 | public function testForcedSoftDeletesCascade() 263 | { 264 | $user = SoftUser::create(['email' => 'user@example.com']); 265 | $user->photos()->create(['name' => 'Avatar 1']); 266 | $user->photos()->create(['name' => 'Avatar 2']); 267 | $friend = $user->friends()->create(['email' => 'friend@example.com']); 268 | $post = $user->posts()->create(['name' => 'First Post']); 269 | $user->comments()->create(['post_id' => $post->id, 'comment' => 'First Comment']); 270 | $user->comments()->create(['post_id' => $post->id, 'comment' => 'Second Comment']); 271 | 272 | $this->assertEquals(7, SoftUser::count() + SoftPost::count() + Photo::count() + Comment::count()); 273 | 274 | $user->forceDelete(); 275 | 276 | $this->assertEquals(1, SoftUser::count()); 277 | $this->assertEquals(0, SoftPost::count()); 278 | $this->assertEquals(1, SoftUser::count() + SoftPost::count() + Photo::count() + Comment::count()); 279 | 280 | $this->assertEquals(1, SoftUser::withTrashed()->count()); 281 | $this->assertEquals(0, SoftPost::withTrashed()->count()); 282 | $this->assertEquals(1, SoftUser::withTrashed()->count() + SoftPost::withTrashed()->count() + Photo::count() + Comment::count()); 283 | } 284 | 285 | public function testSoftDeletesHiddenRelatedRecords() 286 | { 287 | $user = SoftUser::create(['email' => 'user@example.com']); 288 | $user->profile()->create([]); 289 | $user->profile()->create([]); 290 | 291 | $this->assertEquals(3, SoftUser::count() + SoftProfile::count()); 292 | 293 | $user->delete(); 294 | 295 | $this->assertEquals(0, SoftUser::count() + SoftProfile::count()); 296 | $this->assertEquals(3, SoftUser::withTrashed()->count() + SoftProfile::withTrashed()->count()); 297 | } 298 | 299 | public function testForcedSoftDeletesHiddenRelatedRecords() 300 | { 301 | $user = SoftUser::create(['email' => 'user@example.com']); 302 | $user->profile()->create([]); 303 | $user->profile()->create([]); 304 | 305 | $this->assertEquals(3, SoftUser::count() + SoftProfile::count()); 306 | 307 | $user->forceDelete(); 308 | 309 | $this->assertEquals(0, SoftUser::count() + SoftProfile::count()); 310 | $this->assertEquals(0, SoftUser::withTrashed()->count() + SoftProfile::withTrashed()->count()); 311 | } 312 | 313 | public function testForcedSoftDeletesMixedRelatedRecords() 314 | { 315 | $user = SoftUser::create(['email' => 'user@example.com']); 316 | $user->profile()->create([]); 317 | $user->profile()->first()->delete(); 318 | 319 | $this->assertEquals(2, SoftUser::withTrashed()->count() + SoftProfile::withTrashed()->count()); 320 | 321 | $user->profile()->create([]); 322 | 323 | $this->assertEquals(2, SoftUser::count() + SoftProfile::count()); 324 | $this->assertEquals(3, SoftUser::withTrashed()->count() + SoftProfile::withTrashed()->count()); 325 | 326 | $user->forceDelete(); 327 | 328 | $this->assertEquals(0, SoftUser::count() + SoftProfile::count()); 329 | $this->assertEquals(0, SoftUser::withTrashed()->count() + SoftProfile::withTrashed()->count()); 330 | } 331 | 332 | public function testSoftDeletesTransactionIsRolledBack() 333 | { 334 | $user = SoftUser::create(['email' => 'user@example.com']); 335 | $post = $user->posts()->create(['name' => 'First Post']); 336 | $post->photos()->create(['name' => 'Hero 1']); 337 | $post->photos()->create(['name' => 'Hero 2']); 338 | $invalidKid = $post->invalidKids()->create(['name' => 'First Invalid Kid']); 339 | $post->comments()->create(['user_id' => 0, 'comment' => 'First Comment']); 340 | $post->comments()->create(['user_id' => 0, 'comment' => 'Second Comment']); 341 | 342 | $this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count()); 343 | 344 | try { 345 | $exceptionThrown = false; 346 | 347 | $user->delete(); 348 | } catch (LogicException $e) { 349 | $exceptionThrown = true; 350 | } 351 | 352 | $this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count()); 353 | $this->assertTrue($exceptionThrown); 354 | } 355 | 356 | public function testForcedSoftDeletesTransactionIsRolledBack() 357 | { 358 | $user = SoftUser::create(['email' => 'user@example.com']); 359 | $post = $user->posts()->create(['name' => 'First Post']); 360 | $post->photos()->create(['name' => 'Hero 1']); 361 | $post->photos()->create(['name' => 'Hero 2']); 362 | $invalidKid = $post->invalidKids()->create(['name' => 'First Invalid Kid']); 363 | $post->comments()->create(['user_id' => 0, 'comment' => 'First Comment']); 364 | $post->comments()->create(['user_id' => 0, 'comment' => 'Second Comment']); 365 | 366 | $this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count()); 367 | 368 | try { 369 | $exceptionThrown = false; 370 | 371 | $user->forceDelete(); 372 | } catch (LogicException $e) { 373 | $exceptionThrown = true; 374 | } 375 | 376 | $this->assertEquals(7, User::count() + Photo::count() + Post::count() + Comment::count() + InvalidKid::count()); 377 | $this->assertTrue($exceptionThrown); 378 | } 379 | } 380 | --------------------------------------------------------------------------------