Backups: - restore --fresh wipes a dirty/partial schema (same mysql client + $cfg as the import, scoped to DATABASE(), so it can't wipe the wrong DB) after a verified pre-restore safety snapshot; unmissable recovery message on post-wipe failure - workkit:db:verify (+ --verify on backup): decrypt + xz integrity + completion marker + sha256 sidecar check, no DB needed - metadata sidecar per backup; prune --keep-min floor so prune can't leave zero - FK_CHECKS=0 import prepend; deadlock-proof concurrent stdout/stderr drain Ops: - workkit:stats: per-model row counts (estimate/--exact), largest tables, DB size, queue + backup summary; MySQL/MariaDB info_schema, graceful elsewhere - workkit:queue:health: per-queue depth/due/delayed/reserved + oldest-due age, failed-job counts, STALLED detection, DB-error breach, non-zero exit for cron README + CHANGELOG; new config read with code-side defaults (nested-merge safe). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .vscode | ||
| PRINCIPLES | ||
| art | ||
| config | ||
| src | ||
| .dockerignore | ||
| .gitattributes | ||
| .gitignore | ||
| CHANGELOG.md | ||
| README.md | ||
| composer.json | ||
| pint.json | ||
README.md
Laravel Workkit
A Laravel collection of helpers and utilities to reduce redundant code over multiple projects.
Features
- 💾 Streaming DB backups —
mysqldump | xz | opensslin one pipe, APP_KEY-encrypted, zero DB bytes held in PHP memory (multi-GB safe) - ♻️ Safe restores —
--freshwipes a dirty/partial schema before importing, after taking + verifying a recovery snapshot; failed imports never leave you stranded - 🔎 Backup verification —
workkit:db:verifyproves a backup decrypts + is a complete xz stream without a database, so "is it corrupt?" gets a real answer - 🧹 Retention pruning — age-based cleanup that always keeps the N newest, so prune can never delete your last recovery point
- 📊 Stats overview —
workkit:statsprints per-model row counts, the largest tables, total DB size + a queue/backup summary - 🩺 Queue health —
workkit:queue:healthreports per-queue depth/age/failed jobs and exits non-zero on breach (cron-friendly) - 📄 Variable pagination — a
#[VariablePaginatable]attribute +request()->perPage()macro for per-route, user-overridable page sizes - 🧩 Reusable middleware & traits — bearer-token auth, force-JSON responses,
HasExpiration,HasMeta,HasMetaTranslation, and small service helpers
Quick Start
composer require blax-software/laravel-workkit
php artisan workkit:db:backup # storage/backups/db_mysql_<timestamp>.sql.xz.enc
php artisan workkit:db:verify # is the newest backup intact?
php artisan workkit:db:restore --fresh # wipe + restore the newest backup
Backups are encrypted with a key derived from your app's APP_KEY; a backup is
restorable only by a deployment that knows the same APP_KEY.
Database Backups
A streaming, compressed, encrypted MySQL backup/restore suite. The dump, compression and encryption happen in a single shell pipe, so PHP never holds the database content in memory regardless of dump size.
Commands
| Command | What it does |
|---|---|
workkit:db:backup |
Stream mysqldump → xz → openssl into storage/backups, write a .meta.json sidecar (size, sha256, params). |
workkit:db:restore |
Stream openssl → xz → mysql. Verifies the source first; --fresh wipes the target after a verified safety snapshot. |
workkit:db:verify |
Prove a backup decrypts and is a complete xz stream — no database touched. |
workkit:db:prune-backups |
Delete backups older than the retention window, always keeping the N newest. |
Backup
php artisan workkit:db:backup # default connection
php artisan workkit:db:backup --connection=mysql --xz-level=6
php artisan workkit:db:backup --verify # confirm the backup right after writing
Restore
# Newest restorable backup into the default connection (prompts unless --force)
php artisan workkit:db:restore
# A specific file
php artisan workkit:db:restore --file=db_mysql_2026-06-28_09-21-49.sql.xz.enc
# Wipe the target first — the ONLY thing that fixes a restore failing with
# errno 150 / error 3780 against a dirty or partially-migrated schema.
php artisan workkit:db:restore --fresh
--fresh is deliberately careful, because dropping every table is irreversible:
- The source backup is verified before anything is touched — a file that can't be decrypted never triggers a wipe.
- A pre-restore safety snapshot of the current database is taken and
verified (skip with
--no-safety-backup; the restore aborts if the snapshot can't be written, rather than wiping with no recovery point). - Only then are all tables + views dropped — via the same
mysqlclient and database the import uses, so the wipe and the import provably hit the same server (aurlDSN /unix_socket/ read-write split can't make them diverge). - If the import fails after the wipe, the command prints the exact
workkit:db:restore --file=<snapshot>command to recover.
Interactively, --fresh asks you to type the database name to confirm; under
--force it proceeds non-interactively for deploy scripts.
Why
--freshand not just FK checks? A leftover table with an incompatible column type makes MySQL raiseerrno 150/ error3780atCREATE TABLEeven withFOREIGN_KEY_CHECKS=0(which restores already set). The only fix is an empty target. If a restore fails this way, the command tells you to re-run with--fresh.
Verify
php artisan workkit:db:verify # newest backup
php artisan workkit:db:verify --file=db_mysql_2026-06-28_09-21-49.sql.xz.enc
php artisan workkit:db:verify --all # every backup in the directory
Verify proves integrity (decrypts with this host's APP_KEY + a complete,
non-truncated xz stream), flags a missing mysqldump completion marker
(source-truncation), and compares the on-disk sha256 against the .meta.json
sidecar. It does not prove restorability — a valid backup of the wrong or
empty database still verifies "OK".
Prune
php artisan workkit:db:prune-backups --dry-run
php artisan workkit:db:prune-backups --days=30 --keep-min=5
The --keep-min floor (default 5) is always kept regardless of age, so an
aggressive --days can never leave you with zero backups. Schedule it:
Schedule::command('workkit:db:prune-backups')->daily();
Configuration
php artisan vendor:publish --tag=workkit-config
| Key | Env | Default | Purpose |
|---|---|---|---|
backup.path |
WORKKIT_BACKUP_PATH |
storage/backups |
Where backups live |
backup.retention_days |
WORKKIT_BACKUP_RETENTION_DAYS |
30 |
Age cutoff for prune |
backup.retention_min_keep |
WORKKIT_BACKUP_RETENTION_MIN_KEEP |
5 |
Newest backups always kept |
backup.xz_level |
WORKKIT_BACKUP_XZ_LEVEL |
3 |
xz compression level (0–9) |
Requirements
The host needs mysqldump, mysql, xz, openssl and bash on PATH —
standard on any reasonable Linux server. A non-empty APP_KEY is required
(backups are unrecoverable without the key that produced them).
Restoring without the package
The output is plain openssl enc -salt format, so any host with the same
APP_KEY can restore it directly:
openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -pass env:WK_KEY \
-in db_mysql_2026-06-28_09-21-49.sql.xz.enc | xz -d | mysql <database>
# WK_KEY = your APP_KEY with the "base64:" prefix stripped
Ops & Observability
Stats overview
php artisan workkit:stats # models + largest tables + DB size + queue/backup summary
php artisan workkit:stats --exact # true COUNT(*) per table (heavier) instead of the estimate
php artisan workkit:stats --models --json # just per-model counts, machine-readable
php artisan workkit:stats --tables --limit=30
Model row counts are estimated from information_schema on MySQL (instant);
pass --exact for a real COUNT(*). Models are auto-discovered from
app/Models — override with config('workkit.stats.models') (an explicit FQCN
list) or models_path. Table/DB sizes are MySQL-only and degrade to "n/a"
elsewhere.
Queue health
php artisan workkit:queue:health # human table; exits 0 / non-zero
php artisan workkit:queue:health --json || notify-ops # cron / monitoring probe
php artisan workkit:queue:health --max-age=10 --max-depth=500 --max-failed=50
For the database queue driver it reports per-queue pending / due / delayed /
reserved counts and the oldest due job's age, plus failed jobs (total +
last N hours). It flags a queue STALLED — due work older than
max_age_minutes with nothing reserved (the worker is probably down) — and
exits non-zero on any breach so it slots straight into cron or a health
probe. Thresholds live under config('workkit.queue.*'), with per-queue depth
overrides for intentionally-slow queues. Schedule it:
Schedule::command('workkit:queue:health')->everyFiveMinutes();
Changelog
See CHANGELOG.md.