tightenco/quicksand
Quicksand runs your Laravel migrations in a throwaway sandbox database first, so failures and destructive changes surface before you touch production. Configure a sandbox connection, migrate safely, and only apply changes for real once the dry run succeeds.
First, resolve the identity confusion before writing any code. The package metadata, the package name, and the archived status all point to one thing: scheduled cleanup of old soft-deleted Eloquent records. The description.md in the supplied materials describes something else entirely — a migration dry-run/sandbox tool. That description is wrong; do not build against it. Open src/ and composer.json and confirm with your own eyes that this is a pruner. Five minutes of reading beats a day of building on the wrong mental model.
Then decide whether you should install it at all. Laravel has shipped Prunable/MassPrunable and model:prune since v8. This package predates that, which is almost certainly why it was archived (last release 2024-03-12, Laravel ≤11). On any modern Laravel project the correct move is to read this package as a reference implementation and wire pruning natively. Install it only if you are on a pre-Laravel-8 codebase and need a bridge, or you specifically want its config-driven conventions.
If you do install it (you probably shouldn't):
composer require tightenco/quicksand
php artisan vendor:publish # inspect tags; confirm the config file lands where expected
php artisan list | grep -i quicksand # confirm actual command signatures, don't trust docs
Note it is not a --dev dependency if you run it from the scheduler in production — it's runtime code. That distinction matters: a require-dev install will make the scheduled command fail silently in production, which is a nasty class of bug.
First use case: the model you've been soft-deleting into for three years. Pick the table with the most deleted_at IS NOT NULL rows and no retention story. Read the config to see how per-model retention windows are expressed, set one, and run the command manually in a staging environment. Watch the row counts move. That single loop — configure window, run manually, verify counts — is the entire value proposition. Everything else is scheduling it.
Prefer the native pattern; use the package as documentation. This is the highest-leverage "usage" of tightenco/quicksand today. Read the source once, then implement Prunable directly:
class Order extends Model
{
use SoftDeletes, Prunable;
public function prunable(): Builder
{
return static::where('deleted_at', '<=', now()->subDays(90))
->whereNull('legal_hold_at'); // your scoping, not the package's
}
protected function pruning(): void { /* emit audit event, etc. */ }
}
Then schedule model:prune in routes/console.php / Kernel. This gets you the capability without the archived dependency, and with room for the tenant and legal-hold logic the package doesn't provide.
If you are genuinely using the package, drive it entirely from config, not code. The whole point is a declarative map of Model => retention days. Keep that map in config/quicksand.php (or whatever the published file is named — confirm), commit it, and treat changes to it as reviewable policy changes. Do not scatter retention logic into model methods that the package then ignores.
Schedule it off-peak and chunk it. A prune over a large table is a series of DELETE statements. Run it in the low-traffic window, and verify the command chunks rather than issuing one unbounded delete — a single DELETE on millions of rows will lock the table and blow up replication lag. If the package doesn't chunk to your satisfaction, that alone is a reason to go native.
Scope before you schedule. The package cleans soft-deleted rows; it does not know about tenants, legal holds, or parent-child relationships. If any of those apply — and on a multi-tenant SaaS at least one does — do not enable the generic command globally. Either constrain the prunable query per model (native approach) or fork the package, which brings us to:
Treat the package as forkable source, not a dependency. Given the archive status, adopting it means owning it. The source is small. If you need tenancy scoping, custom reporting, or per-environment behaviour, fork it into your app/ and name an internal owner. That is more honest than wrapping an unmaintained package and pretending you'll upgrade it eventually.
Wire tests around the retention boundary, whichever path you take. Assert rows older than the window are gone and rows inside it survive, and assert your legal-hold and tenant constraints actually exclude rows. That test is the thing that catches a future config typo that flips a window to 0 days.
Read it as a migration aid. If you're moving from the package to native Prunable, the package's config gives you a ready-made inventory of retention windows per model. Port that map to prunable() constraints, one model per PR, with the boundary test above. Delete the config file when the last model is ported.
The repository is archived. Laravel 11 is the ceiling; there will be no Laravel 12 compatibility release, and nobody owes you a fix. Check composer.json constraints yourself — don't infer support from the version string. If your roadmap includes an L12 upgrade, adopting this only schedules the next blocker. Do not make it load-bearing.
The documentation in the wild is unreliable — including the copy you were handed. The supplied description contradicts the metadata, the package name, and the archived status. Always verify behaviour against src/ and a real run, not against prose. This is a package where the README and the reality may have drifted, and where a bad description can send you down the wrong integration path entirely.
Retention is a policy decision, not a cron expression. The code is trivial; deciding 90 days for orders, 30 for carts, never for financial records under legal hold is the actual work, and it belongs to product/legal, documented, and referenced in your DPA. The package — or Prunable — is just the mechanism. Get the policy in writing before you write the schedule, or you'll be re-litigating window values in code review forever.
Foreign keys and cascades will surprise you. Purging a soft-deleted parent hard-deletes it, which may cascade to children or fail on an FK constraint. The generic pruner cannot reason about this. Decide explicitly per relationship: cascade, orphan, or block the purge. That logic belongs in domain code with tests, not in a retention config map.
Auditability is destroyed by naive pruning. Once a row is force-deleted, you've lost the record of who deleted it and when. If that evidence matters to support, security, or legal, you need an archive table or an event log written before the pruning step. The package has no opinion here; neither does native Prunable. This is the most common post-adoption regret.
Don't confuse "pruned successfully" with "retention is compliant." Pruning removes old soft-deletes; it does not prove the window you pruned at matches what you told customers. Ship the tests that assert the boundary, and keep the windows in one place — config or a constant — so policy and code can't drift apart.
Scheduling quirks: confirm the command name with artisan list (don't trust examples), confirm it's in require and not require-dev, and be aware that php artisan config:cache in a deploy pipeline freezes config values — clear and re-cache around any config change, or your new retention window silently won't take effect.
When it misbehaves, debug the query, not the package. The failure modes are almost always: a prunable scope that matches nothing (wrong date column, wrong comparison direction), an FK constraint rejecting the delete, or the command not being scheduled at all. Log the count the query selects before it runs — if that number is zero, the problem is your scope, not the pruner.
How can I help you explore Laravel packages today?