From b3ac9efbfc0a28646b23ad6b3c998495cb454648 Mon Sep 17 00:00:00 2001 From: yoeriwalstra Date: Fri, 22 May 2026 17:00:04 +0200 Subject: [PATCH 1/4] add limit option to ResourceCleanupCommand --- .../Commands/ResourceCleanupCommand.php | 25 ++++++++--- tests/Feature/ResourceCleanupCommandTest.php | 42 +++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/Console/Commands/ResourceCleanupCommand.php b/src/Console/Commands/ResourceCleanupCommand.php index ef6a820..0bec15a 100644 --- a/src/Console/Commands/ResourceCleanupCommand.php +++ b/src/Console/Commands/ResourceCleanupCommand.php @@ -21,7 +21,8 @@ class ResourceCleanupCommand extends Command protected $signature = 'resource-cleanup:run {--model=* : One or more fully-qualified model class names to clean up} {--dry-run : Show what would be deleted without actually deleting} - {--skip-index-check : Skip the created_at index validation (not recommended, may cause slow queries)}'; + {--skip-index-check : Skip the created_at index validation (not recommended, may cause slow queries)} + {--limit= : Maximum number of records to delete per model (0 or omitted means no limit)}'; protected $description = 'Permanently delete records older than the configured cutoff date.'; @@ -44,6 +45,8 @@ public function handle(): int return self::FAILURE; } + $limit = $this->option('limit') !== null ? (int) $this->option('limit') : 0; + $totalDeleted = 0; foreach ($models as $modelClass) { try { @@ -56,10 +59,11 @@ public function handle(): int if ($this->option('dry-run')) { $count = $query->count(); + $effective = $limit > 0 ? min($count, $limit) : $count; - $this->line(sprintf('[dry-run] %s: %d record(s) would be deleted.', $modelClass, $count)); + $this->line(sprintf('[dry-run] %s: %d record(s) would be deleted.', $modelClass, $effective)); - $totalDeleted += $count; + $totalDeleted += $effective; continue; } @@ -67,11 +71,22 @@ public function handle(): int $deleted = 0; $query->chunkById( config('resource-cleanup.cleanup_chunk_size'), - function (Collection $records) use ($modelClass, &$deleted) { - $deleted += $modelClass::query()->whereIn('id', $records->pluck('id'))->forceDelete(); + function (Collection $records) use ($modelClass, &$deleted, $limit) { + $ids = $records->pluck('id'); + + if ($limit > 0) { + $ids = $ids->take($limit - $deleted); + } + + $deleted += $modelClass::query()->whereIn('id', $ids)->forceDelete(); // 25ms sleep gives the DB breathing room for consecutive queries usleep(25000); + + // break out of query chunk loop if limit is reached + if ($limit > 0 && $deleted >= $limit) { + return false; + } }, ); diff --git a/tests/Feature/ResourceCleanupCommandTest.php b/tests/Feature/ResourceCleanupCommandTest.php index 0f8cb2e..c3df5d3 100644 --- a/tests/Feature/ResourceCleanupCommandTest.php +++ b/tests/Feature/ResourceCleanupCommandTest.php @@ -196,6 +196,48 @@ public function test_resource_cleanup_uses_scope_cleanable_on_soft_deletable_cle $this->assertSame(0, TestCleanableSoftDeletableResource::withTrashed()->where('name', 'older-than-one-year')->count()); } + // ------------------------------------------------------------------------- + // Limit option + // ------------------------------------------------------------------------- + + public function test_resource_cleanup_deletes_at_most_limit_records(): void + { + $this->app['config']->set('resource-cleanup.models', [TestResource::class]); + + $old = Carbon::now()->subDays(91); + TestResource::create(['name' => 'old-1', 'created_at' => $old]); + TestResource::create(['name' => 'old-2', 'created_at' => $old]); + TestResource::create(['name' => 'old-3', 'created_at' => $old]); + TestResource::create(['name' => 'old-4', 'created_at' => $old]); + TestResource::create(['name' => 'old-5', 'created_at' => $old]); + + $this->artisan('resource-cleanup:run', ['--limit' => 3]) + ->expectsOutput(TestResource::class . ': 3 record(s) deleted.') + ->expectsOutput('Done. Total: 3 record(s) deleted.') + ->assertSuccessful(); + + $this->assertSame(2, TestResource::count()); + } + + public function test_dry_run_respects_limit_option(): void + { + $this->app['config']->set('resource-cleanup.models', [TestResource::class]); + + $old = Carbon::now()->subDays(91); + TestResource::create(['name' => 'old-1', 'created_at' => $old]); + TestResource::create(['name' => 'old-2', 'created_at' => $old]); + TestResource::create(['name' => 'old-3', 'created_at' => $old]); + TestResource::create(['name' => 'old-4', 'created_at' => $old]); + TestResource::create(['name' => 'old-5', 'created_at' => $old]); + + $this->artisan('resource-cleanup:run', ['--dry-run' => true, '--limit' => 3]) + ->expectsOutput('[dry-run] ' . TestResource::class . ': 3 record(s) would be deleted.') + ->expectsOutput('Done. Total: 3 record(s) would be deleted.') + ->assertSuccessful(); + + $this->assertSame(5, TestResource::count()); + } + // ------------------------------------------------------------------------- // Failure cases // ------------------------------------------------------------------------- From f6a040489687ffb7f8f2234618e5f08fe2c6da93 Mon Sep 17 00:00:00 2001 From: yoeri walstra <48599583+yoerriwalstra@users.noreply.github.com> Date: Tue, 26 May 2026 14:01:09 +0200 Subject: [PATCH 2/4] Update src/Console/Commands/ResourceCleanupCommand.php Co-authored-by: Daniel --- src/Console/Commands/ResourceCleanupCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Console/Commands/ResourceCleanupCommand.php b/src/Console/Commands/ResourceCleanupCommand.php index 0bec15a..93e1402 100644 --- a/src/Console/Commands/ResourceCleanupCommand.php +++ b/src/Console/Commands/ResourceCleanupCommand.php @@ -45,7 +45,7 @@ public function handle(): int return self::FAILURE; } - $limit = $this->option('limit') !== null ? (int) $this->option('limit') : 0; + $limit = (int) ($this->option('limit') ?? 0); $totalDeleted = 0; foreach ($models as $modelClass) { From a0a90494244383200a1e31e6037e090d6e418c5a Mon Sep 17 00:00:00 2001 From: yoeriwalstra Date: Tue, 26 May 2026 16:05:13 +0200 Subject: [PATCH 3/4] set default_retention_days config value to default value 180 --- config/resource-cleanup.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/resource-cleanup.php b/config/resource-cleanup.php index cee2c48..14c80e8 100644 --- a/config/resource-cleanup.php +++ b/config/resource-cleanup.php @@ -14,7 +14,7 @@ | implementing the CleanableResource contract. | */ - 'default_retention_days' => env('RESOURCE_CLEANUP_RETENTION_DAYS', 90), + 'default_retention_days' => env('RESOURCE_CLEANUP_RETENTION_DAYS', 180), /* |-------------------------------------------------------------------------- From 27466444a345c7c98fa3d18b506a1cefbca944b3 Mon Sep 17 00:00:00 2001 From: yoeriwalstra Date: Wed, 27 May 2026 10:55:53 +0200 Subject: [PATCH 4/4] fix tests and update phpunit XML to v11 --- phpunit.xml | 3 +-- tests/Feature/ResourceCleanupCommandTest.php | 16 ++++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index d1347f3..06d34a5 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,9 +1,8 @@ diff --git a/tests/Feature/ResourceCleanupCommandTest.php b/tests/Feature/ResourceCleanupCommandTest.php index c3df5d3..54ebce5 100644 --- a/tests/Feature/ResourceCleanupCommandTest.php +++ b/tests/Feature/ResourceCleanupCommandTest.php @@ -22,7 +22,7 @@ public function test_dry_run_without_soft_deletes(): void { $this->app['config']->set('resource-cleanup.models', [TestResource::class]); - $old = Carbon::now()->subDays(91); + $old = Carbon::now()->subDays(181); TestResource::create(['name' => 'old-1', 'created_at' => $old]); TestResource::create(['name' => 'old-2', 'created_at' => $old]); TestResource::create(['name' => 'old-3', 'created_at' => $old]); @@ -40,7 +40,7 @@ public function test_dry_run_with_soft_deletes(): void { $this->app['config']->set('resource-cleanup.models', [TestSoftDeletableResource::class]); - $old = Carbon::now()->subDays(91); + $old = Carbon::now()->subDays(181); TestSoftDeletableResource::create(['name' => 'old-1', 'created_at' => $old])->delete(); TestSoftDeletableResource::create(['name' => 'old-2', 'created_at' => $old])->delete(); TestSoftDeletableResource::create(['name' => 'old-3', 'created_at' => $old])->delete(); @@ -61,7 +61,7 @@ public function test_resource_cleanup_deletes_old_records_without_soft_deletes() { $this->app['config']->set('resource-cleanup.models', [TestResource::class]); - $old = Carbon::now()->subDays(91); + $old = Carbon::now()->subDays(181); TestResource::create(['name' => 'old-1', 'created_at' => $old]); TestResource::create(['name' => 'old-2', 'created_at' => $old]); TestResource::create(['name' => 'old-3', 'created_at' => $old]); @@ -80,7 +80,7 @@ public function test_resource_cleanup_deletes_old_soft_deleted_records(): void { $this->app['config']->set('resource-cleanup.models', [TestSoftDeletableResource::class]); - $old = Carbon::now()->subDays(91); + $old = Carbon::now()->subDays(181); TestSoftDeletableResource::create(['name' => 'old-deleted-1', 'created_at' => $old])->delete(); TestSoftDeletableResource::create(['name' => 'old-deleted-2', 'created_at' => $old])->delete(); TestSoftDeletableResource::create(['name' => 'old-not-deleted', 'created_at' => $old]); @@ -118,7 +118,7 @@ public function test_resource_cleanup_valid_model_options(): void TestCleanableSoftDeletableResource::class, ]); - $old = Carbon::now()->subDays(91); + $old = Carbon::now()->subDays(181); TestSoftDeletableResource::create(['name' => 'old', 'created_at' => $old])->delete(); $this->artisan('resource-cleanup:run', ['--model' => [TestSoftDeletableResource::class]]) @@ -204,7 +204,7 @@ public function test_resource_cleanup_deletes_at_most_limit_records(): void { $this->app['config']->set('resource-cleanup.models', [TestResource::class]); - $old = Carbon::now()->subDays(91); + $old = Carbon::now()->subDays(181); TestResource::create(['name' => 'old-1', 'created_at' => $old]); TestResource::create(['name' => 'old-2', 'created_at' => $old]); TestResource::create(['name' => 'old-3', 'created_at' => $old]); @@ -223,7 +223,7 @@ public function test_dry_run_respects_limit_option(): void { $this->app['config']->set('resource-cleanup.models', [TestResource::class]); - $old = Carbon::now()->subDays(91); + $old = Carbon::now()->subDays(181); TestResource::create(['name' => 'old-1', 'created_at' => $old]); TestResource::create(['name' => 'old-2', 'created_at' => $old]); TestResource::create(['name' => 'old-3', 'created_at' => $old]); @@ -273,7 +273,7 @@ public function test_resource_cleanup_skips_index_check_when_flag_is_provided(): { $this->app['config']->set('resource-cleanup.models', [TestResourceWithoutIndex::class]); - $old = Carbon::now()->subDays(91); + $old = Carbon::now()->subDays(181); TestResourceWithoutIndex::create(['name' => 'old', 'created_at' => $old]); TestResourceWithoutIndex::create(['name' => 'recent']);