├── .github └── workflows │ ├── style.yml │ └── tests.yml ├── .gitignore ├── .php-cs-fixer.cache ├── LICENSE.md ├── README.md ├── composer.json ├── config └── waterfall.php ├── phpunit.xml ├── src ├── Jobs │ └── Job.php ├── ServiceProvider.php ├── Support │ ├── InteractsWithArray.php │ └── InteractsWithDatabase.php └── Tasks │ ├── HardDeleteTask.php │ ├── SoftDeleteTask.php │ └── Task.php ├── tests ├── Jobs │ ├── DeleteUserAfterHookJob.php │ ├── DeleteUserBatchJob.php │ ├── DeleteUserBeforeAfterHookJob.php │ ├── DeleteUserBeforeHookJob.php │ ├── DeleteUserJob.php │ ├── DeleteUserUsingKeyJob.php │ ├── DeleteUserUsingRestrictionsAndKeyJob.php │ └── DeleteUserUsingRestrictionsJob.php ├── Migrations │ ├── 2014_10_12_000000_create_users_table.php │ ├── 2014_10_12_000001_create_posts_table.php │ └── 2014_10_12_000002_create_jobs_table.php ├── Models │ ├── Post.php │ └── User.php ├── Test.php └── World │ └── Builder.php └── tools └── .php-cs-fixer.php /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: styling 2 | 3 | on: [push] 4 | 5 | jobs: 6 | style: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: Checkout code 11 | uses: actions/checkout@v2 12 | 13 | - name: Run PHP CS Fixer 14 | uses: docker://oskarstark/php-cs-fixer-ga 15 | with: 16 | args: --config=tools/.php-cs-fixer.php --allow-risky=yes 17 | 18 | - name: Extract branch name 19 | shell: bash 20 | run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" 21 | id: extract_branch 22 | 23 | - name: Commit changes 24 | uses: stefanzweifel/git-auto-commit-action@v2.3.0 25 | with: 26 | commit_message: Fix styling 27 | branch: ${{ steps.extract_branch.outputs.branch }} 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | phpunit: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ubuntu-latest] 12 | php: [8.1] 13 | laravel: [8.*] 14 | dependency-version: [prefer-stable] 15 | include: 16 | - laravel: 8.* 17 | testbench: 6.* 18 | 19 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.dependency-version }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v2 24 | 25 | - name: Install SQLite 26 | run: | 27 | sudo apt-get update 28 | sudo apt-get install sqlite3 29 | 30 | - name: Setup PHP 31 | uses: shivammathur/setup-php@v2 32 | with: 33 | php-version: ${{ matrix.php }} 34 | extensions: curl, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, iconv 35 | coverage: none 36 | 37 | - name: Install dependencies 38 | run: | 39 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 40 | composer update --${{ matrix.dependency-version }} --prefer-dist --no-interaction 41 | 42 | - name: Execute tests 43 | run: vendor/bin/phpunit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | composer.lock 3 | vendor 4 | storage 5 | tests/World/database.sqlite 6 | .DS_Store 7 | coverage 8 | .phpunit.result.cache 9 | .idea 10 | .php_cs.cache -------------------------------------------------------------------------------- /.php-cs-fixer.cache: -------------------------------------------------------------------------------- 1 | {"php":"8.1.12","version":"3.13.0","indent":" ","lineEnding":"\n","rules":{"blank_line_after_namespace":true,"braces":true,"class_definition":true,"constant_case":true,"elseif":true,"function_declaration":{"closure_function_spacing":"none"},"indentation_type":true,"line_ending":true,"lowercase_keywords":true,"method_argument_space":{"on_multiline":"ensure_fully_multiline","keep_multiple_spaces_after_comma":true},"no_break_comment":true,"no_closing_tag":true,"no_space_around_double_colon":true,"no_spaces_after_function_name":true,"no_spaces_inside_parenthesis":true,"no_trailing_whitespace":true,"no_trailing_whitespace_in_comment":true,"single_blank_line_at_eof":true,"single_class_element_per_statement":{"elements":["property"]},"single_import_per_statement":true,"single_line_after_imports":true,"switch_case_semicolon_to_colon":true,"switch_case_space":true,"visibility_required":{"elements":["method","property"]},"encoding":true,"full_opening_tag":true,"array_syntax":{"syntax":"short"},"ordered_imports":{"sort_algorithm":"length"},"no_unused_imports":true,"not_operator_with_successor_space":true,"trailing_comma_in_multiline":{"elements":["arrays"]},"phpdoc_scalar":true,"unary_operator_spaces":true,"binary_operator_spaces":{"operators":{"=":"align","=>":"align"}},"blank_line_before_statement":{"statements":["break","continue","declare","return","throw","try"]},"phpdoc_single_line_var_spacing":true,"phpdoc_var_without_name":true,"class_attributes_separation":{"elements":{"method":"one","property":"one"}},"method_chaining_indentation":true,"object_operator_without_whitespace":true,"no_superfluous_phpdoc_tags":true},"hashes":{"src\/Tasks\/SoftDeleteTask.php":"71fbf9b2eed43ffff4f86849fdb26756","src\/Tasks\/HardDeleteTask.php":"6f3081c584e1ee8ca8c95538c646fb34","src\/Tasks\/Task.php":"6c30491b2192aef4cef2551312baa5a6","src\/Support\/InteractsWithArray.php":"aded89d55951e19bbe19ae784a9c908a","src\/Support\/InteractsWithDatabase.php":"70ebfc5ee74f506fe466bc55802a4ca4","src\/Jobs\/Job.php":"aebb28574d88910f6240ef735d285a44","src\/ServiceProvider.php":"04fbe263a82780e02736b3605ab78093","tests\/Jobs\/DeleteUserBeforeAfterHookJob.php":"e6ba68a6df73185974679f3f4be3cacb","tests\/Jobs\/DeleteUserUsingRestrictionsAndKeyJob.php":"1960570bcdb68f0ec0adcf5c14452a82","tests\/Jobs\/DeleteUserJob.php":"09e67e150caf017e879e5860a77a8f77","tests\/Jobs\/DeleteUserUsingKeyJob.php":"42ff8102ce0d5cf1311c3fc4dc87a41e","tests\/Jobs\/DeleteUserUsingRestrictionsJob.php":"4b25c58992adb609342ba5517c3711c9","tests\/Jobs\/DeleteUserAfterHookJob.php":"3b58075fe559bf33aee058d2e8995205","tests\/Jobs\/DeleteUserBeforeHookJob.php":"b2d5615abbfb3f44ffe5a32ea41b0ec6","tests\/Jobs\/DeleteUserBatchJob.php":"6d92bbf32f80331be816b2ccefad002d","tests\/World\/Builder.php":"d3d462f29e593e502f826e04a5dfee34","tests\/Test.php":"93326ee04d164d2bc1cd8a740c995457","tests\/Migrations\/2014_10_12_000001_create_posts_table.php":"1d8f745f0287854ff3db0406c1365437","tests\/Migrations\/2014_10_12_000000_create_users_table.php":"2036153809f3a1973882dd94502a3eac","tests\/Migrations\/2014_10_12_000002_create_jobs_table.php":"5bbf8d40f0ec8d52fbc95318572c1965","tests\/Models\/Post.php":"b9a4d941dd964491e0973a1c7875d613","tests\/Models\/User.php":"54b42a5f55b2a8399d927ece313a8cdc"}} -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright © Caneara and contributors 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Waterfall 2 | 3 | This package enables a Laravel application to perform database cascading delete operations in staggered batches. The primary benefit of this approach, is that it avoids overwhelming the database with a massive amount of record deletion tasks when operating large-scale applications e.g. analytic platforms. 4 | 5 | ## Who is this for? 6 | 7 | If you're building a small application, or your database is unlikely to ever see cascade deletions exceeding 8 | a few thousand records, then you can probably make do without this package. Simply enforce cascade deleting 9 | in your migrations as you normally would. 10 | 11 | If your database contains or will contain hundreds of thousands, millions or even billions of records, and the deletion of a record will cause a cascade involving a similar number of records, then it is likely to overwhelm your database. In this scenario, Waterfall can be a good choice. 12 | 13 | The package is also a good choice if you're using a so-called 'NewSQL' platform that doesn't offer cascade deletion (e.g. because of a lack of foreign key constraints). 14 | 15 | ## How does it work? 16 | 17 | The process is fairly simple. You define a job e.g. `DeleteUserJob` that extends Waterfall's own `Waterfall\Jobs\Job`. Within this job, you configure the cascade tasks that need to be performed. When you're ready to delete a 'user' record, you dispatch `DeleteUserJob` and provide it with the ID of the 'user'. 18 | 19 | Waterfall will then perform the following tasks 20 | 21 | 1. Soft delete the main record (user). 22 | 2. Iterate through the cascade tasks. 23 | 1. Delete a batch of related records (1000). 24 | 2. If more records are available, sleep for a short time, then dispatch another job to repeat (i). 25 | 3. If no more records are available, sleep for a short time, then dispatch another job to continue 2. 26 | 3. When all tasks have been completed, hard delete the main record (user). 27 | 28 | ## Installation 29 | 30 | Pull in the package using Composer: 31 | 32 | ```bash 33 | composer require caneara/waterfall 34 | ``` 35 | 36 | ## Upgrading 37 | 38 | Version 2 introduces a completely different way of configuring tasks. In version 1, tasks were configured using a series of parameters on the `create` factory method, however with the addition of hooks, this became quite cumbersome. 39 | 40 | As a result, the `Task` class was refactored to provide a series of setter methods that can be chained together to build up the task. It is recommended that you re-read all of the readme when upgrading. 41 | 42 | ## Configuration 43 | 44 | Waterfall includes a configuration file that allows you to: 45 | 46 | 1. Set the queue name to use for the cascade deletion jobs (defaults to 'deletion'). 47 | 2. Set the batch size / number of records to delete per query (defaults to 1000). 48 | 3. Set the rest time in seconds to give the database between batches (defaults to 5). 49 | 50 | If you wish to change any of these values, publish the configuration file using Artisan: 51 | 52 | ```bash 53 | php artisan vendor:publish 54 | ``` 55 | 56 | Note that as of version 2, you can override the batch size and rest time within individual tasks. Therefore, if you intend to set custom values for your tasks and are happy with the default queue name, there is no need to publish the configuration file. 57 | 58 | ## Queues 59 | 60 | Make sure to only create a small number of workers for the queue e.g. 2 or 3. Too many workers risks overwhelming the database, which completely negates the purpose of the package. 61 | 62 | ## Usage 63 | 64 | In order for Waterfall to delete records without triggering a cascade, the associated `Model` class must implement Laravel's built-in soft deleting. Begin by adding the `SoftDeletes` trait to the model class e.g. 65 | 66 | ```php 67 | model(Post::class) 114 | ]; 115 | } 116 | ``` 117 | 118 | Waterfall will intepret this as 119 | 120 | ```sql 121 | DELETE FROM `posts` WHERE `user_id` = ? LIMIT 1000 122 | ``` 123 | 124 | You can also use the `table` method if you prefer e.g. 125 | 126 | ```php 127 | use Waterfall\Tasks\Task; 128 | 129 | protected function tasks() : array 130 | { 131 | return [ 132 | Task::create() 133 | ->table('posts') 134 | ]; 135 | } 136 | ``` 137 | 138 | #### Configuring the foreign key 139 | 140 | Notice how Waterfall has guessed the foreign key by using the class we defined for `$type`. In many cases, this will be correct. However, if you need to use a different key, then you can set it explicitly using the `key` method: 141 | 142 | ```php 143 | protected function tasks() : array 144 | { 145 | return [ 146 | Task::create() 147 | ->model(Post::class) 148 | ->key('author_id') 149 | ]; 150 | } 151 | ``` 152 | 153 | #### Modifying the query 154 | 155 | In many cases, you'll simply want to delete all records associated with the main record. However, if you need to be more specific e.g. you want to include a `WHERE` condition, or add a `JOIN`, then you can modify the query. 156 | 157 | To do this, call the `query` method and supply a `Closure` that accepts the current `$query` as a parameter. You are then free to modify the query however you wish e.g. 158 | 159 | ```php 160 | protected function tasks() : array 161 | { 162 | return [ 163 | Task::create() 164 | ->model(Post::class) 165 | ->key('author_id') 166 | ->query(function($query) { 167 | return $query->where('year', 2022); 168 | }) 169 | ]; 170 | } 171 | ``` 172 | 173 | Waterfall will intepret this as 174 | 175 | ```sql 176 | DELETE FROM `posts` WHERE `author_id` = ? AND `year` = 2022 LIMIT 1000 177 | ``` 178 | 179 | #### Adjusting batch size and rest time 180 | 181 | By default, Waterfall will use the batch size and rest time defined within its configuration file (which you can publish and alter if desired). However, you can override these values for individual tasks if you need to. This is particularly useful if you are making use of hooks (which we will explore in a moment). 182 | 183 | To alter the batch size or rest time, call the `batch` or `rest` methods respectively e.g. 184 | 185 | ```php 186 | protected function tasks() : array 187 | { 188 | return [ 189 | Task::create() 190 | ->model(Post::class) 191 | ->batch(10) // retrieve up to 10 records at a time 192 | ->rest(15) // delay follow-up jobs for the task by 15 seconds 193 | ]; 194 | } 195 | ``` 196 | 197 | The `rest` method also accepts a `Carbon` instance e.g. 198 | 199 | ```php 200 | Task::create()->rest(300) // 5 minutes 201 | Task::create()->rest(now()->addMinutes(5)) // 5 minutes 202 | ``` 203 | 204 | #### Adding hooks to your tasks 205 | 206 | When deleting related records, you may need to undertake additional steps. For example, blog posts might have banner images stored on disk. When we delete the blog posts, we need to delete those images to. Waterfall allows for this by including a hooks feature. 207 | 208 | You can hook into a task either before a batch deletion, after a batch deletion, or both. To do this, call the `before` or `after` method and supply a `Closure` that accepts an `$items` parameter. The `$items` parameter is an instance of a `Collection` containing the current batch of records. 209 | 210 | > For performance and memory reasons, the records are retrieved as simple objects. If you want models, then call the `hydrate` method on the task e.g. `Task::create()->model(Post::class)->hydrate()->before(...)`. 211 | 212 | Let's see how we might delete the banner images in the above example: 213 | 214 | ```php 215 | protected function tasks() : array 216 | { 217 | return [ 218 | Task::create() 219 | ->model(Post::class) 220 | ->before(function($items) { 221 | $items->each(function($post) { 222 | Storage::delete($post->banner_path); 223 | }) 224 | }) 225 | ]; 226 | } 227 | ``` 228 | 229 | It is important to understand that using hooks requires Waterfall to fetch the batch of records from the database e.g. perform a `SELECT` query. When hooks are not being used, Waterfall just performs a `DELETE` query, which is much more efficient. 230 | 231 | If you want to use hooks, then there are some important things to keep in mind: 232 | 233 | 1. For large records, you risk running out memory. Therefore, make sure to set a lower batch size. 234 | 2. If you have large records and only need the IDs or a subset of columns, then consider using the `query` method to select only the required fields. This will put less strain on the database and reduce the risk of running out of memory. 235 | 3. Hooks require additional processing time, so make sure to give your jobs a sufficient timeout. 236 | 237 | ### How to order your tasks 238 | 239 | In order to prevent cascading delete operations from taking place, you have to perform your delete tasks in reverse. To better illustrate this, consider the following example database: 240 | 241 | ``` 242 | users -> posts -> likes 243 | ``` 244 | 245 | If you were to delete a 'user', it would trigger a cascade to delete all of the user's 'posts', and then all of the 'likes' accumulated for the user's 'posts'. Likewise, if you were to just delete a 'post', it would not delete the 'user', but it would cause a cascade to delete all the 'likes' accumulated for the 'post'. We therefore have to perform the deletion tasks in the following order: 246 | 247 | ``` 248 | likes -> posts -> users 249 | ``` 250 | 251 | Here's a complete job example that covers how to do this. Note that this assumes that 'posts' has a `author_id` foreign key, and that 'likes' has `post_id` foreign key. 252 | 253 | ```php 254 | model(Like::class) 273 | ->key('posts.author_id') 274 | ->query(function($query) { 275 | return $query->join('posts', 'likes.post_id', '=', 'posts.id'); 276 | })), 277 | 278 | Task::create() 279 | ->model(Post::class) 280 | ->key('author_id') 281 | ->query(function($query) { 282 | return $query->select(['id', 'author_id', 'banner_path']); 283 | })), 284 | ->before(function($items) { 285 | $items->each(function($post) { 286 | Storage::delete($post->banner_path); 287 | }) 288 | }) 289 | ]; 290 | } 291 | } 292 | ``` 293 | 294 | Waterfall will intepret this as 295 | 296 | ```sql 297 | DELETE FROM `likes` INNER JOIN `posts` ON `likes`.`post_id` = `posts`.`id` WHERE `posts`.`author_id` = ? LIMIT 1000 298 | DELETE FROM `posts` WHERE `author_id` = ? LIMIT 1000 299 | ``` 300 | 301 | ### Dispatching the job 302 | 303 | Once all of the tasks have been configured and the `$type` set on the main job class, all that remains, is to dispatch the job and supply the ID of the record to remove. 304 | 305 | Continuing on from our example, if we wanted to delete a user with an ID of 6, we'd run the following code: 306 | 307 | ```php 308 | DeleteUserJob::dispatch(6); 309 | ``` 310 | 311 | ## Enabling cascade deletions at a database level 312 | 313 | There are different schools of thought on whether you should continue to enable cascading deletes within your database migrations. 314 | 315 | In theory, if you are using this package and your jobs are correctly configured, it should not be necessary to enable cascading deletes. However, if the job misses something, and you have disabled cascading deletes, then your database will throw an error (potentially leaving you with corrupted data). 316 | 317 | Another approach, is to disable cascading deletes in development, and then enable them when deploying to production. Using this strategy, you will hopefully discover any issues with the task lists of your jobs before your code gets into production. However, if something does get through, the database will at least ensure that the data is not corrupted (though potentially at the risk of a crippling deletion). 318 | 319 | > It should also be noted that if you do disable cascading deletes, then deleting records outside of your application becomes cumbersome e.g. within a database tool like MySQL Workbench. 320 | 321 | ## A word on transactions 322 | 323 | Since Waterfall performs deletions in batches and with pauses to give the database time to execute its tasks, it is not possible to use transactions. As best as I can see, there is no way round this as any long-running task using a transaction would probably lock up the database, thereby negating the entire performance benefit brought by Waterfall. 324 | 325 | If someone is able to come up with an efficient way to make transactions work in this configuration, I'd be more than happy to entertain a PR. 326 | 327 | ## Contributing 328 | 329 | Thank you for considering a contribution to Waterfall. You are welcome to submit a PR containing improvements, however if they are substantial in nature, please also be sure to include a test or tests. 330 | 331 | ## License 332 | 333 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 334 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "caneara/waterfall", 3 | "description": "A package that performs cascading deletes in batches to ease database strain", 4 | "keywords": [ 5 | "waterfall", 6 | "php", 7 | "laravel", 8 | "database", 9 | "cascade" 10 | ], 11 | "type": "library", 12 | "license": "MIT", 13 | "homepage": "https://github.com/caneara/waterfall", 14 | "autoload": { 15 | "psr-4": { 16 | "Waterfall\\": "src" 17 | } 18 | }, 19 | "autoload-dev": { 20 | "psr-4": { 21 | "Waterfall\\Tests\\": "tests" 22 | } 23 | }, 24 | "require": { 25 | "php": "^8.0" 26 | }, 27 | "require-dev": { 28 | "orchestra/testbench": "^6.0", 29 | "phpunit/phpunit": "^9.0" 30 | }, 31 | "extra": { 32 | "laravel": { 33 | "providers": [ 34 | "Waterfall\\ServiceProvider" 35 | ] 36 | } 37 | }, 38 | "scripts": { 39 | "test": "vendor/bin/phpunit" 40 | }, 41 | "minimum-stability": "stable" 42 | } 43 | -------------------------------------------------------------------------------- /config/waterfall.php: -------------------------------------------------------------------------------- 1 | 'deletions', 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Batch Size 22 | |-------------------------------------------------------------------------- 23 | | 24 | | This value controls the maximum number of records that Waterfall will 25 | | attempt to delete per query. In most cases, the database should be fine 26 | | with the default figure, however if the database is under strain, then 27 | | you might want to consider lowering it. 28 | | 29 | */ 30 | 31 | 'batch_size' => 1000, 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | Rest Time 36 | |-------------------------------------------------------------------------- 37 | | 38 | | This value controls the number of seconds that Waterfall will wait when 39 | | dispatching a follow-up job to continue the deletion process. The pause 40 | | is added to the dispatched job using the standard 'delay' method. 41 | | 42 | */ 43 | 44 | 'rest_time' => 5, 45 | 46 | ]; 47 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src/ 6 | 7 | 8 | 9 | 10 | tests 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Jobs/Job.php: -------------------------------------------------------------------------------- 1 | id = $id; 61 | $this->task = $task; 62 | $this->queue = config('waterfall.queue_name'); 63 | 64 | $this->task === 0 ? $this->handle(true) : null; 65 | } 66 | 67 | /** 68 | * Execute the query and fetch records for the current task in the given pipeline. 69 | * 70 | */ 71 | protected function fetch(array $pipeline) : Collection 72 | { 73 | $items = $pipeline[$this->task] 74 | ->generate($this->id) 75 | ->get(); 76 | 77 | if ($pipeline[$this->task]->hydrate) { 78 | $items = $pipeline[$this->task]->model::hydrate($items->toArray()); 79 | } 80 | 81 | return $items; 82 | } 83 | 84 | /** 85 | * Execute the job. 86 | * 87 | */ 88 | public function handle(bool $skip = false) 89 | { 90 | if (static::unavailable()) { 91 | return $this->release(10); 92 | } 93 | 94 | $pipeline = $this->pipeline(); 95 | 96 | $records = $this->hasHook($pipeline) ? static::attempt(fn () => $this->fetch($pipeline)) : null; 97 | 98 | // before 99 | if ($this->hasBeforeHook($pipeline)) { 100 | $pipeline[$this->task]['before']($records); 101 | } 102 | 103 | // delete 104 | $deleted = static::attempt(fn () => $this->remove($pipeline, $records)); 105 | 106 | // after 107 | if ($this->hasAfterHook($pipeline)) { 108 | $pipeline[$this->task]['after']($records); 109 | } 110 | 111 | $batch = $pipeline[$this->task]->batch ?? config('waterfall.batch_size'); 112 | 113 | $this->task += $deleted < $batch ? 1 : 0; 114 | 115 | $this->task < count($pipeline) ? $this->proceed($skip) : null; 116 | } 117 | 118 | /** 119 | * Determine if the current task has an after hook assigned to it. 120 | * 121 | */ 122 | protected function hasAfterHook(array $pipeline) : bool 123 | { 124 | return filled($pipeline[$this->task]->after); 125 | } 126 | 127 | /** 128 | * Determine if the current task has a before hook assigned to it. 129 | * 130 | */ 131 | protected function hasBeforeHook(array $pipeline) : bool 132 | { 133 | return filled($pipeline[$this->task]->before); 134 | } 135 | 136 | /** 137 | * Determine if the current task has a before or after hook assigned to it. 138 | * 139 | */ 140 | protected function hasHook(array $pipeline) : bool 141 | { 142 | return $this->hasBeforeHook($pipeline) || $this->hasAfterHook($pipeline); 143 | } 144 | 145 | /** 146 | * Log the delay times between jobs for testing and debugging. 147 | * 148 | */ 149 | private function log(Job $job) : static 150 | { 151 | if (! ($_ENV['waterfall_debug'] ?? false)) { 152 | return $job; 153 | } 154 | 155 | $_ENV['duration'] = ($_ENV['duration'] ?? 0) + $job->delay; 156 | 157 | return $job; 158 | } 159 | 160 | /** 161 | * Retrieve the complete set of tasks to perform. 162 | * 163 | */ 164 | protected function pipeline() : array 165 | { 166 | return array_merge( 167 | [SoftDeleteTask::create()->model(static::$type)], 168 | $this->tasks(), 169 | [HardDeleteTask::create()->model(static::$type)], 170 | ); 171 | } 172 | 173 | /** 174 | * Dispatch a follow-up job to continue the deletion process. 175 | * 176 | */ 177 | protected function proceed(bool $skip) : void 178 | { 179 | if ($skip) { 180 | return; 181 | } 182 | 183 | $job = (new static($this->id, $this->task)); 184 | 185 | if ($this->connection !== 'sync') { 186 | $job = $job->delay(config('waterfall.rest_time')); 187 | } 188 | 189 | dispatch($this->log($job))->onConnection($this->connection); 190 | } 191 | 192 | /** 193 | * Execute the query and remove records for the current task in the given pipeline. 194 | * 195 | */ 196 | protected function remove(array $pipeline, Collection $records = null) : int 197 | { 198 | $result = $pipeline[$this->task]->generate($this->id); 199 | 200 | if (! $result instanceof Builder) { 201 | return $result; 202 | } 203 | 204 | return $result 205 | ->when(filled($records), fn ($query) => $query->whereIn('id', $records->pluck('id'))) 206 | ->delete(); 207 | } 208 | 209 | /** 210 | * Assign the list of deletion tasks. 211 | * 212 | */ 213 | abstract protected function tasks() : array; 214 | } 215 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | publishes([ 16 | __DIR__ . '/../config/waterfall.php' => config_path('waterfall.php'), 17 | ]); 18 | } 19 | 20 | /** 21 | * Register any package services. 22 | * 23 | */ 24 | public function register() : void 25 | { 26 | $this->mergeConfigFrom(__DIR__ . '/../config/waterfall.php', 'waterfall'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Support/InteractsWithArray.php: -------------------------------------------------------------------------------- 1 | {$offset}; 23 | } 24 | 25 | /** 26 | * Assign the value for a given offset. 27 | * 28 | */ 29 | public function offsetSet(mixed $offset, mixed $value): void 30 | { 31 | $this->{$offset} = $value; 32 | } 33 | 34 | /** 35 | * Clear the value for a given offset. 36 | * 37 | */ 38 | public function offsetUnset(mixed $offset): void 39 | { 40 | unset($this->{$offset}); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Support/InteractsWithDatabase.php: -------------------------------------------------------------------------------- 1 | $closure(), 250); 18 | } 19 | 20 | /** 21 | * Determine if the database is not available. 22 | * 23 | */ 24 | protected static function unavailable() : bool 25 | { 26 | try { 27 | return ! ! ! DB::connection()->getPdo(); 28 | } catch (Exception) { 29 | return true; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Tasks/HardDeleteTask.php: -------------------------------------------------------------------------------- 1 | table)->where('id', $id); 18 | 19 | return static::attempt(fn () => $query->delete()); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Tasks/SoftDeleteTask.php: -------------------------------------------------------------------------------- 1 | now(), 19 | 'updated_at' => now(), 20 | ]; 21 | 22 | $query = DB::table($this->table)->where('id', $id); 23 | 24 | return static::attempt(fn () => $query->update($payload)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Tasks/Task.php: -------------------------------------------------------------------------------- 1 | after = null; 78 | $this->batch = null; 79 | $this->before = null; 80 | $this->hydrate = false; 81 | $this->key = ''; 82 | $this->model = ''; 83 | $this->query = null; 84 | $this->rest = null; 85 | $this->table = ''; 86 | } 87 | 88 | /** 89 | * Set the closure that should be executed after the delete operation. 90 | * 91 | */ 92 | public function after(Closure $hook) : static 93 | { 94 | $this->after = $hook; 95 | 96 | return $this; 97 | } 98 | 99 | /** 100 | * Set the maximum number of records to include in a batch. 101 | * 102 | */ 103 | public function batch(int $total) : static 104 | { 105 | $this->batch = $total; 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * Set the closure that should be executed before the delete operation. 112 | * 113 | */ 114 | public function before(Closure $hook) : static 115 | { 116 | $this->before = $hook; 117 | 118 | return $this; 119 | } 120 | 121 | /** 122 | * Generate a new cascading deletion task. 123 | * 124 | */ 125 | public static function create() : static 126 | { 127 | return new static(); 128 | } 129 | 130 | /** 131 | * Create the database query for the task. 132 | * 133 | * @internal. 134 | * 135 | */ 136 | public function generate(mixed $id) : mixed 137 | { 138 | return DB::table($this->table) 139 | ->where($this->guessKey(), $id) 140 | ->when(filled($this->query), fn ($query) => $this['query']($query)) 141 | ->limit($this->batch ?? config('waterfall.batch_size')); 142 | } 143 | 144 | /** 145 | * Retrieve the foreign key that should be used. 146 | * 147 | */ 148 | protected function guessKey() : string 149 | { 150 | if (filled($this->key)) { 151 | return $this->key; 152 | } 153 | 154 | $class = class_basename(debug_backtrace()[2]['object']::$type); 155 | 156 | return (string) Str::of($class)->lower()->singular()->append('_id'); 157 | } 158 | 159 | /** 160 | * Set whether the records accessed via hooks should be converted to models. 161 | * 162 | */ 163 | public function hydrate() : static 164 | { 165 | $this->hydrate = true; 166 | 167 | return $this; 168 | } 169 | 170 | /** 171 | * Set the foreign key that links the relation to the main model. 172 | * 173 | */ 174 | public function key(string $name) : static 175 | { 176 | $this->key = $name; 177 | 178 | return $this; 179 | } 180 | 181 | /** 182 | * Set the model that the task should run upon. 183 | * 184 | */ 185 | public function model(string $class) : static 186 | { 187 | $this->model = $class; 188 | 189 | return $this->table((new $class())->getTable()); 190 | } 191 | 192 | /** 193 | * Set the closure that should be executed on the database query. 194 | * 195 | */ 196 | public function query(Closure $query) : static 197 | { 198 | $this->query = $query; 199 | 200 | return $this; 201 | } 202 | 203 | /** 204 | * Set the delay time to wait between jobs. 205 | * 206 | */ 207 | public function rest(mixed $seconds) : static 208 | { 209 | $this->rest = is_int($seconds) ? $seconds : $seconds->endOfSecond()->diffInSeconds(now()); 210 | 211 | return $this; 212 | } 213 | 214 | /** 215 | * Set the table that the task should run upon. 216 | * 217 | */ 218 | public function table(string $name) : static 219 | { 220 | $this->table = $name; 221 | 222 | return $this; 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /tests/Jobs/DeleteUserAfterHookJob.php: -------------------------------------------------------------------------------- 1 | model(Post::class) 27 | ->after(fn ($items) => $_ENV['after_items'] = $items->pluck('id')), 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Jobs/DeleteUserBatchJob.php: -------------------------------------------------------------------------------- 1 | model(Post::class) 27 | ->batch(2) 28 | ->rest(now()->addSeconds(5)), 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Jobs/DeleteUserBeforeAfterHookJob.php: -------------------------------------------------------------------------------- 1 | model(Post::class) 27 | ->before(fn ($items) => $_ENV['before_items'] = $items->pluck('id')) 28 | ->after(fn ($items) => $_ENV['after_items'] = $items->pluck('id')), 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Jobs/DeleteUserBeforeHookJob.php: -------------------------------------------------------------------------------- 1 | model(Post::class) 27 | ->hydrate() 28 | ->before(fn ($items) => $_ENV['before_items'] = $items), 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Jobs/DeleteUserJob.php: -------------------------------------------------------------------------------- 1 | model(Post::class), 27 | ]; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Jobs/DeleteUserUsingKeyJob.php: -------------------------------------------------------------------------------- 1 | model(Post::class) 27 | ->key('user_id'), 28 | ]; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Jobs/DeleteUserUsingRestrictionsAndKeyJob.php: -------------------------------------------------------------------------------- 1 | model(Post::class) 27 | ->key('user_id') 28 | ->query(function($query) { 29 | return $query->where('title', 'Lorem ipsum'); 30 | }), 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Jobs/DeleteUserUsingRestrictionsJob.php: -------------------------------------------------------------------------------- 1 | model(Post::class) 27 | ->query(function($query) { 28 | return $query->where('title', 'Lorem ipsum'); 29 | }), 30 | ]; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Migrations/2014_10_12_000000_create_users_table.php: -------------------------------------------------------------------------------- 1 | unsignedTinyInteger('id'); 17 | $table->string('name'); 18 | $table->softDeletes(); 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | */ 27 | public function down() : void 28 | { 29 | Schema::dropIfExists('users'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Migrations/2014_10_12_000001_create_posts_table.php: -------------------------------------------------------------------------------- 1 | unsignedTinyInteger('id'); 17 | $table->unsignedTinyInteger('user_id'); 18 | $table->string('title'); 19 | $table->timestamps(); 20 | }); 21 | } 22 | 23 | /** 24 | * Reverse the migrations. 25 | * 26 | */ 27 | public function down() : void 28 | { 29 | Schema::dropIfExists('posts'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /tests/Migrations/2014_10_12_000002_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 17 | $table->string('queue')->index(); 18 | $table->longText('payload'); 19 | $table->unsignedTinyInteger('attempts'); 20 | $table->unsignedInteger('reserved_at')->nullable(); 21 | $table->unsignedInteger('available_at'); 22 | $table->unsignedInteger('created_at'); 23 | }); 24 | } 25 | 26 | /** 27 | * Reverse the migrations. 28 | * 29 | */ 30 | public function down() : void 31 | { 32 | Schema::dropIfExists('jobs'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/Models/Post.php: -------------------------------------------------------------------------------- 1 | register(); 32 | 33 | $this->loadMigrationsFrom(__DIR__ . '/Migrations'); 34 | 35 | Builder::seed(); 36 | } 37 | 38 | /** 39 | * Destroy the test environment. 40 | * 41 | */ 42 | protected function tearDown() : void 43 | { 44 | Builder::destroy(); 45 | } 46 | 47 | /** @test */ 48 | public function it_soft_deletes_the_main_record_immediately() : void 49 | { 50 | $_ENV['waterfall_debug'] = false; 51 | $_ENV['duration'] = null; 52 | 53 | app('config')->set('queue.default', 'database'); 54 | 55 | $this->assertCount(2, User::get()); 56 | $this->assertCount(8, Post::get()); 57 | 58 | DeleteUserJob::dispatch(1); 59 | 60 | $this->assertCount(1, User::get()); 61 | $this->assertCount(8, Post::get()); 62 | 63 | $this->assertNull($_ENV['duration']); 64 | } 65 | 66 | /** @test */ 67 | public function it_can_delete_users() : void 68 | { 69 | $_ENV['waterfall_debug'] = false; 70 | $_ENV['duration'] = null; 71 | 72 | $this->assertCount(2, User::get()); 73 | $this->assertCount(8, Post::get()); 74 | 75 | DeleteUserJob::dispatch(1); 76 | 77 | $this->assertCount(1, User::get()); 78 | $this->assertCount(4, Post::get()); 79 | 80 | $this->assertEquals(2, User::first()->id); 81 | $this->assertEquals(5, Post::orderBy('id')->get()[0]->id); 82 | $this->assertEquals(6, Post::orderBy('id')->get()[1]->id); 83 | $this->assertEquals(7, Post::orderBy('id')->get()[2]->id); 84 | $this->assertEquals(8, Post::orderBy('id')->get()[3]->id); 85 | 86 | $this->assertNull($_ENV['duration']); 87 | } 88 | 89 | /** @test */ 90 | public function it_can_delete_users_using_a_custom_key() : void 91 | { 92 | $_ENV['waterfall_debug'] = false; 93 | $_ENV['duration'] = null; 94 | 95 | $this->assertCount(2, User::get()); 96 | $this->assertCount(8, Post::get()); 97 | 98 | DeleteUserUsingKeyJob::dispatch(1); 99 | 100 | $this->assertCount(1, User::get()); 101 | $this->assertCount(4, Post::get()); 102 | 103 | $this->assertEquals(2, User::first()->id); 104 | $this->assertEquals(5, Post::orderBy('id')->get()[0]->id); 105 | $this->assertEquals(6, Post::orderBy('id')->get()[1]->id); 106 | $this->assertEquals(7, Post::orderBy('id')->get()[2]->id); 107 | $this->assertEquals(8, Post::orderBy('id')->get()[3]->id); 108 | 109 | $this->assertNull($_ENV['duration']); 110 | } 111 | 112 | /** @test */ 113 | public function it_can_delete_users_using_restrictions() : void 114 | { 115 | $_ENV['waterfall_debug'] = false; 116 | $_ENV['duration'] = null; 117 | 118 | $this->assertCount(2, User::get()); 119 | $this->assertCount(8, Post::get()); 120 | 121 | DeleteUserUsingRestrictionsJob::dispatch(1); 122 | 123 | $this->assertCount(1, User::get()); 124 | $this->assertCount(7, Post::get()); 125 | 126 | $this->assertEquals(2, User::first()->id); 127 | $this->assertEquals(2, Post::orderBy('id')->get()[0]->id); 128 | $this->assertEquals(3, Post::orderBy('id')->get()[1]->id); 129 | $this->assertEquals(4, Post::orderBy('id')->get()[2]->id); 130 | $this->assertEquals(5, Post::orderBy('id')->get()[3]->id); 131 | $this->assertEquals(6, Post::orderBy('id')->get()[4]->id); 132 | $this->assertEquals(7, Post::orderBy('id')->get()[5]->id); 133 | $this->assertEquals(8, Post::orderBy('id')->get()[6]->id); 134 | 135 | $this->assertNull($_ENV['duration']); 136 | } 137 | 138 | /** @test */ 139 | public function it_can_delete_users_using_restrictions_and_a_custom_key() : void 140 | { 141 | $_ENV['waterfall_debug'] = false; 142 | $_ENV['duration'] = null; 143 | 144 | $this->assertCount(2, User::get()); 145 | $this->assertCount(8, Post::get()); 146 | 147 | DeleteUserUsingRestrictionsAndKeyJob::dispatch(1); 148 | 149 | $this->assertCount(1, User::get()); 150 | $this->assertCount(7, Post::get()); 151 | 152 | $this->assertEquals(2, User::first()->id); 153 | $this->assertEquals(2, Post::orderBy('id')->get()[0]->id); 154 | $this->assertEquals(3, Post::orderBy('id')->get()[1]->id); 155 | $this->assertEquals(4, Post::orderBy('id')->get()[2]->id); 156 | $this->assertEquals(5, Post::orderBy('id')->get()[3]->id); 157 | $this->assertEquals(6, Post::orderBy('id')->get()[4]->id); 158 | $this->assertEquals(7, Post::orderBy('id')->get()[5]->id); 159 | $this->assertEquals(8, Post::orderBy('id')->get()[6]->id); 160 | 161 | $this->assertNull($_ENV['duration']); 162 | } 163 | 164 | /** @test */ 165 | public function it_can_delete_users_in_batches() : void 166 | { 167 | $_ENV['waterfall_debug'] = true; 168 | $_ENV['duration'] = null; 169 | 170 | $this->assertCount(2, User::get()); 171 | $this->assertCount(8, Post::get()); 172 | 173 | DeleteUserBatchJob::dispatch(1); 174 | 175 | $this->assertCount(1, User::get()); 176 | $this->assertCount(4, Post::get()); 177 | 178 | $this->assertEquals(2, User::first()->id); 179 | $this->assertEquals(5, Post::orderBy('id')->get()[0]->id); 180 | $this->assertEquals(6, Post::orderBy('id')->get()[1]->id); 181 | $this->assertEquals(7, Post::orderBy('id')->get()[2]->id); 182 | $this->assertEquals(8, Post::orderBy('id')->get()[3]->id); 183 | 184 | $this->assertEquals(15, $_ENV['duration']); 185 | } 186 | 187 | /** @test */ 188 | public function it_can_delete_users_and_calls_the_before_hook() : void 189 | { 190 | $_ENV['waterfall_debug'] = false; 191 | $_ENV['duration'] = null; 192 | $_ENV['before_items'] = null; 193 | $_ENV['after_items'] = null; 194 | 195 | $this->assertCount(2, User::get()); 196 | $this->assertCount(8, Post::get()); 197 | 198 | DeleteUserBeforeHookJob::dispatch(1); 199 | 200 | $this->assertCount(1, User::get()); 201 | $this->assertCount(4, Post::get()); 202 | 203 | $this->assertEquals(2, User::first()->id); 204 | $this->assertEquals(5, Post::orderBy('id')->get()[0]->id); 205 | $this->assertEquals(6, Post::orderBy('id')->get()[1]->id); 206 | $this->assertEquals(7, Post::orderBy('id')->get()[2]->id); 207 | $this->assertEquals(8, Post::orderBy('id')->get()[3]->id); 208 | 209 | $this->assertNull($_ENV['duration']); 210 | $this->assertNull($_ENV['after_items']); 211 | 212 | $this->assertEquals($_ENV['before_items']->pluck('id'), collect([1, 2, 3, 4])); 213 | } 214 | 215 | /** @test */ 216 | public function it_can_delete_users_and_calls_the_after_hook() : void 217 | { 218 | $_ENV['waterfall_debug'] = false; 219 | $_ENV['duration'] = null; 220 | $_ENV['before_items'] = null; 221 | $_ENV['after_items'] = null; 222 | 223 | $this->assertCount(2, User::get()); 224 | $this->assertCount(8, Post::get()); 225 | 226 | DeleteUserAfterHookJob::dispatch(1); 227 | 228 | $this->assertCount(1, User::get()); 229 | $this->assertCount(4, Post::get()); 230 | 231 | $this->assertEquals(2, User::first()->id); 232 | $this->assertEquals(5, Post::orderBy('id')->get()[0]->id); 233 | $this->assertEquals(6, Post::orderBy('id')->get()[1]->id); 234 | $this->assertEquals(7, Post::orderBy('id')->get()[2]->id); 235 | $this->assertEquals(8, Post::orderBy('id')->get()[3]->id); 236 | 237 | $this->assertNull($_ENV['duration']); 238 | $this->assertNull($_ENV['before_items']); 239 | 240 | $this->assertEquals($_ENV['after_items'], collect([1, 2, 3, 4])); 241 | } 242 | 243 | /** @test */ 244 | public function it_can_delete_users_and_calls_the_before_and_after_hook() : void 245 | { 246 | $_ENV['waterfall_debug'] = false; 247 | $_ENV['duration'] = null; 248 | $_ENV['before_items'] = null; 249 | $_ENV['after_items'] = null; 250 | 251 | $this->assertCount(2, User::get()); 252 | $this->assertCount(8, Post::get()); 253 | 254 | DeleteUserBeforeAfterHookJob::dispatch(1); 255 | 256 | $this->assertCount(1, User::get()); 257 | $this->assertCount(4, Post::get()); 258 | 259 | $this->assertEquals(2, User::first()->id); 260 | $this->assertEquals(5, Post::orderBy('id')->get()[0]->id); 261 | $this->assertEquals(6, Post::orderBy('id')->get()[1]->id); 262 | $this->assertEquals(7, Post::orderBy('id')->get()[2]->id); 263 | $this->assertEquals(8, Post::orderBy('id')->get()[3]->id); 264 | 265 | $this->assertNull($_ENV['duration']); 266 | 267 | $this->assertEquals($_ENV['before_items'], collect([1, 2, 3, 4])); 268 | $this->assertEquals($_ENV['after_items'], collect([1, 2, 3, 4])); 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /tests/World/Builder.php: -------------------------------------------------------------------------------- 1 | 'sqlite', 29 | 'database' => __DIR__ . '/database.sqlite', 30 | ]; 31 | 32 | $queue = [ 33 | 'driver' => 'database', 34 | 'table' => 'jobs', 35 | 'queue' => 'default', 36 | 'retry_after' => 90, 37 | 'after_commit' => false, 38 | ]; 39 | 40 | app('config')->set('database.default', 'sqlite'); 41 | app('config')->set('database.migrations', 'migrations'); 42 | app('config')->set('database.connections.sqlite', $database); 43 | 44 | app('config')->set('queue.default', 'sync'); 45 | app('config')->set('queue.connections.database', $queue); 46 | app('config')->set('queue.connections.sync', ['driver' => 'sync']); 47 | } 48 | 49 | /** 50 | * Destroy the world. 51 | * 52 | */ 53 | public static function destroy() : void 54 | { 55 | @unlink(__DIR__ . '/database.sqlite'); 56 | } 57 | 58 | /** 59 | * Seed the database. 60 | * 61 | */ 62 | public static function seed() : void 63 | { 64 | DB::table('users')->truncate(); 65 | DB::table('posts')->truncate(); 66 | 67 | DB::table('users')->insert(['id' => 1, 'name' => 'John Doe']); 68 | DB::table('users')->insert(['id' => 2, 'name' => 'Jane Doe']); 69 | 70 | DB::table('posts')->insert(['id' => 1, 'user_id' => 1, 'title' => 'Lorem ipsum']); 71 | DB::table('posts')->insert(['id' => 2, 'user_id' => 1, 'title' => 'Dolor sit']); 72 | DB::table('posts')->insert(['id' => 3, 'user_id' => 1, 'title' => 'Amet consectetur']); 73 | DB::table('posts')->insert(['id' => 4, 'user_id' => 1, 'title' => 'Adipiscing elit']); 74 | DB::table('posts')->insert(['id' => 5, 'user_id' => 2, 'title' => 'Sed do']); 75 | DB::table('posts')->insert(['id' => 6, 'user_id' => 2, 'title' => 'Eiusmod tempor']); 76 | DB::table('posts')->insert(['id' => 7, 'user_id' => 2, 'title' => 'Incididunt ut']); 77 | DB::table('posts')->insert(['id' => 8, 'user_id' => 2, 'title' => 'Labore et']); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /tools/.php-cs-fixer.php: -------------------------------------------------------------------------------- 1 | notPath(dirname(__DIR__, 1) . '/bootstrap/*') 5 | ->notPath(dirname(__DIR__, 1) . '/storage/*') 6 | ->notPath(dirname(__DIR__, 1) . '/vendor') 7 | ->notPath(dirname(__DIR__, 1) . '/resources/view/mail/*') 8 | ->in([ 9 | dirname(__DIR__, 1) . '/src', 10 | dirname(__DIR__, 1) . '/tests', 11 | ]) 12 | ->name('*.php') 13 | ->notName('*.blade.php') 14 | ->ignoreDotFiles(true) 15 | ->ignoreVCS(true); 16 | 17 | return (new PhpCsFixer\Config()) 18 | ->setRules([ 19 | '@PSR2' => true, 20 | 'array_syntax' => ['syntax' => 'short'], 21 | 'ordered_imports' => ['sort_algorithm' => 'length'], 22 | 'no_unused_imports' => true, 23 | 'not_operator_with_successor_space' => true, 24 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 25 | 'phpdoc_scalar' => true, 26 | 'unary_operator_spaces' => true, 27 | 'binary_operator_spaces' => [ 28 | 'operators' => ['=' => 'align', '=>' => 'align'], 29 | ], 30 | 'blank_line_before_statement' => [ 31 | 'statements' => ['break', 'continue', 'declare', 'return', 'throw', 'try'], 32 | ], 33 | 'phpdoc_single_line_var_spacing' => true, 34 | 'phpdoc_var_without_name' => true, 35 | 'class_attributes_separation' => [ 36 | 'elements' => [ 37 | 'method' => 'one', 38 | 'property' => 'one', 39 | ], 40 | ], 41 | 'method_argument_space' => [ 42 | 'on_multiline' => 'ensure_fully_multiline', 43 | 'keep_multiple_spaces_after_comma' => true, 44 | ], 45 | 'method_chaining_indentation' => true, 46 | 'object_operator_without_whitespace' => true, 47 | 'no_superfluous_phpdoc_tags' => true, 48 | 'function_declaration' => [ 49 | 'closure_function_spacing' => 'none', 50 | ], 51 | ]) 52 | ->setFinder($finder); 53 | --------------------------------------------------------------------------------