helgesverre/laravel-prunekeeper
PruneKeeper gives Laravel apps a central home for model pruning: define retention rules, schedule prune commands, and track what gets cleaned up so your database stays lean without scattered ad-hoc cleanup scripts or guesswork.
This assessment is written against v2.0.0, the first stable release. Treat the static Prunekeeper::* API and the published config file as the supported surface — v2 deliberately follows the Laravel first-party package conventions (Cache::, Bus::, etc.), so anything you learned from pre-1.0 docs that used instance methods or fluent chaining should be discarded.
zip driver needs ext-zip; gzip needs ext-zlib; bzip2 needs ext-bz2; targz needs both ext-phar and ext-zlib. Only ext-zip/ext-zlib are commonly present, so verify your target runtime (local, CI image, prod container) before choosing a driver.
composer require helgesverre/laravel-prunekeeper
php -m | grep -E 'zip|zlib|bz2|phar'
prunekeeper.compression.driver.
php artisan vendor:publish --tag="prunekeeper-config"
activity_log / audit_entries / notifications table, scope it to rows older than 90 days, and export before deleting. Run it with the export enabled but deletion disabled (or against a local copy of prod data) so you can inspect the archive and confirm the row count matches expectations before the first real run.The order to read code in: config/prunekeeper.php → the Artisan command signature (--compression, dry-run/export flags) → Prunekeeper::makePrunableQuery() if any of your models use SoftDeletes.
Register prunable models in config with a query callback. The callback is where retention policy lives — keep it declarative and free of side effects, because v2 requires these callbacks to return void (they mutate the query they are handed, or resolve one via the helper below). Don't try to return anything for chaining.
Soft deletes: use Prunekeeper::makePrunableQuery() rather than hand-rolling scopes. It builds the base query with the soft-delete semantics applied, so you don't have to remember whether to add whereNull('deleted_at') or onlyTrashed(). If you need to hard-delete already-trashed rows after a grace period, that's a separate prunable entry — keep the two policies (soft-delete grace period vs. permanent purge) in distinct config blocks so their retention windows can't be confused.
Pick the driver per environment, and be explicit. zip locally (universally available, openable in Finder/Explorer), targz or gzip in production where archive size matters for object-storage cost. Set it in config per environment, but pin it in the scheduled command's --compression flag if a silent driver change would break downstream consumers of the archive filenames — the extension changes with the driver.
Schedule it, but don't let it run unobserved.
Schedule::command('prunekeeper:prune')
->dailyAt('02:30')
->withoutOverlapping()
->onOneServer()
->runInBackground();
withoutOverlapping matters: a prune run that exceeds its window and starts a second concurrent run is how you get double exports and lock contention on the same rows.
Export-to-storage, then delete, then verify. The pattern worth standardising: prune writes a compressed archive, you retain it for your compliance window (S3 lifecycle rule, etc.), and the DB delete happens after the archive is durably written. v2's "exporters clean up temp files on failure" means a failed export leaves no partial garbage on the local disk — but it also means a failed export should abort the delete, so assert that behaviour in a test rather than assuming it.
Test isolation is now a first-class concern. Prunekeeper::flushState() exists because the package holds static state. Call it in setUp() (or a tearDown()) of any test touching the package, and add a base-test-class hook if you have many:
protected function setUp(): void
{
parent::setUp();
Prunekeeper::flushState();
}
Without this you will get order-dependent failures where a prunable model registered in one test leaks into the next.
CI must install the extensions you actually use. Add ext-bz2 (and ext-phar) to the CI image if any environment is configured for bzip2/targz, otherwise the failure surfaces only in the prod deploy path.
void. Any pre-1.0 config that did return $query->where(...) or relied on fluent chaining ($this return) must be rewritten as a statement-only callback. This is the most likely source of a silent upgrade bug, because a returned query is simply ignored rather than erroring loudly.bzip2 on a host without ext-bz2; the failure appears at 02:30 in production. Consider an assertion in a service provider boot or a health check that validates the configured driver's requirements.makePrunableQuery() is the soft-delete trap. If you bypass it and write a raw Model::query(), you will silently include or exclude trashed rows depending on the model's global scopes — and the bug looks like "we deleted the wrong number of rows," which is hard to notice until you need the archive.deleting/deleted observers will not fire. Any audit trail, cache invalidation, search-index removal, or S3 cleanup hooked to those events will not run. Handle those explicitly (before/after the run, or via a queued reconciliation job).--compression overrides config. Useful for one-off backfills, dangerous in cron: a flag left in a scheduled command silently diverges from the configured default and changes archive extensions downstream.DELETE over millions of rows holds locks and bloats the replication stream. If the package doesn't chunk internally, scope by ID ranges or date windows and run several smaller prunes.count() around the same scoping logic (or use the package's dry-run mode) and compare against a manual SELECT COUNT(*) — off-by-one timezone handling between now() in app timezone and the DB column is the classic way to prune one extra day of data.cascadeOnDelete in a realistic dataset; the delete can succeed on the parent and surprise you at the child level, or fail entirely on restrict.void return contract.How can I help you explore Laravel packages today?