myparcelcom / resource-cleanup
Clean up soft-deleted and expired records from your Laravel application after a configurable cutoff date.
Requires
- php: ^8.1
- illuminate/console: ^8.0|^9.0|^10.0|^11.0|^12.0|^13.0
- illuminate/database: ^8.0|^9.0|^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^8.0|^9.0|^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^6.0|^7.0|^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^9.0|^10.0|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-02 15:29:31 UTC
README
A Laravel package to permanently delete soft-deleted or expired Eloquent records after a configurable cutoff date.
Requirements
- PHP 8.1+
- Laravel 8, 9, 10, 11, or 12
Installation
composer require myparcelcom/resource-cleanup
Update Laravel's package auto-discovery cache
php artisan package:discover
Publish the config file:
php artisan vendor:publish --tag=resource-cleanup-config
Configuration
config/resource-cleanup.php:
return [ /* * Records older than this many days will be permanently deleted when no * custom CleanableResource scope is defined on the model. * Default: 180 days. Override with the RESOURCE_CLEANUP_RETENTION_DAYS env var. */ 'default_retention_days' => env('RESOURCE_CLEANUP_RETENTION_DAYS', 180), /* * Number of records fetched per chunk during deletion. * Reduce this if you experience memory pressure on large tables. */ 'cleanup_chunk_size' => env('RESOURCE_CLEANUP_CHUNK_SIZE', 500), /* * Fully-qualified class names of the Eloquent models that the cleanup * command is allowed to process. The command will refuse any --model * option that is not present in this list. */ 'models' => [ \App\Models\Order::class, \App\Models\AuditLog::class, ], ];
Usage
Artisan command
Run cleanup for all models listed in the config:
php artisan resource-cleanup:run
Target one or more specific models from the config:
php artisan resource-cleanup:run --model="App\Models\Order" php artisan resource-cleanup:run --model="App\Models\Order" --model="App\Models\AuditLog"
Preview what would be deleted without actually deleting anything:
php artisan resource-cleanup:run --dry-run
Delete at most N records per model in a single run:
php artisan resource-cleanup:run --limit=1000
Skip the created_at index validation (not recommended — may cause slow queries on large tables):
php artisan resource-cleanup:run --skip-index-check
How deletion works
For each model the command:
- Builds a query using the model's
scopeCleanablescope (if it implementsCleanableResource) or the default cutoff query (see below). - Fetches records in chunks of
cleanup_chunk_sizeusingchunkById, ordered by the model's primary key. - Deletes each chunk with a single
DELETE WHERE primary_key IN (...)query that re-applies all original query conditions, ensuring no record outside the original scope is accidentally deleted — even if primary key values are reused over time. - Sleeps 25 ms between chunks to give the database breathing room.
For models that use soft deletes, the default query automatically includes withTrashed() so that both soft-deleted and non-deleted records older than the cutoff are permanently removed.
Default cutoff query
When a model does not implement CleanableResource, the command builds the following query:
Model::query() ->where('created_at', '<', Carbon::now()->subDays(config('resource-cleanup.default_retention_days'))) ->withTrashed() // only added when the model uses SoftDeletes
The table must have an index on created_at for this query to perform well. The command validates this at startup and aborts with an error if no such index exists. Use --skip-index-check to bypass the check (not recommended for production).
Custom retention period with CleanableResource
Implement CleanableResource on a model to provide a fully custom Eloquent scope that determines which records are eligible for deletion. This bypasses the default created_at cutoff and index check entirely.
use Illuminate\Database\Eloquent\Builder; use MyParcelCom\ResourceCleanup\Contracts\CleanableResource; class AuditLog extends Model implements CleanableResource { public static function scopeCleanable(): Builder { return self::query() ->where('created_at', '<', now()->subYear()) ->where('status', 'processed'); } }
The scope must return a Builder. The command calls ->count() on it for --dry-run runs, and ->chunkById() + forceDelete() for actual deletion — identical to the default path.
Tip:
scopeCleanableis a standard Laravel local scope. You can call it directly in application code viaAuditLog::cleanable()if you ever need the query outside of the command.
Non-standard primary keys
The command uses Model::getKeyName() to resolve the primary key column. If your model declares a custom $primaryKey, no additional configuration is required — the command will use it automatically.
Testing
With Docker (recommended)
A PostgreSQL service is included in docker-compose.yml. Run the full test suite:
docker compose run --rm app
Run a specific test or filter by name:
docker compose run --rm app vendor/bin/phpunit --filter test_dry_run_without_soft_deletes
Drop into a shell inside the container:
docker compose run --rm app bash
Without Docker
composer install vendor/bin/phpunit