2026-05-07 05:18:00 +00:00
[](https://github.com/blax-software)
# Laravel Workkit
feat: backup hardening + verify, stats overview, queue health
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>
2026-06-29 10:39:03 +00:00
[](https://php.net)
[](https://laravel.com)
[](LICENSE)
2026-05-07 05:18:00 +00:00
A Laravel collection of helpers and utilities to reduce redundant code over multiple projects.
feat: backup hardening + verify, stats overview, queue health
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>
2026-06-29 10:39:03 +00:00
## Features
- 💾 **Streaming DB backups** — `mysqldump | xz | openssl` in one pipe, APP_KEY-encrypted, zero DB bytes held in PHP memory (multi-GB safe)
- ♻️ **Safe restores** — `--fresh` wipes a dirty/partial schema before importing, after taking + verifying a recovery snapshot; failed imports never leave you stranded
- 🔎 **Backup verification** — `workkit:db:verify` proves 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:stats` prints per-model row counts, the largest tables, total DB size + a queue/backup summary
- 🩺 **Queue health** — `workkit:queue:health` reports 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
```bash
composer require blax-software/laravel-workkit
```
```bash
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
```bash
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
```bash
# 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:
1. The **source backup is verified** before anything is touched — a file that
can't be decrypted never triggers a wipe.
2. 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).
3. Only then are all tables + views dropped — via the **same** `mysql` client
and database the import uses, so the wipe and the import provably hit the
same server (a `url` DSN / `unix_socket` / read-write split can't make them
diverge).
4. 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 `--fresh` and not just FK checks?** A leftover table with an
> incompatible column type makes MySQL raise `errno 150` / error `3780` at
> `CREATE TABLE` even with `FOREIGN_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
```bash
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
```bash
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:
```php
Schedule::command('workkit:db:prune-backups')->daily();
```
### Configuration
```bash
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:
```bash
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
```bash
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
```bash
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:
```php
Schedule::command('workkit:queue:health')->everyFiveMinutes();
```
## Changelog
See [CHANGELOG.md ](CHANGELOG.md ).
## Star History
< a href = "https://www.star-history.com/?repos=blax-software%2Flaravel-workkit&type=date&legend=top-left" >
< picture >
< source media = "(prefers-color-scheme: dark)" srcset = "https://api.star-history.com/chart?repos=blax-software/laravel-workkit&type=date&theme=dark&legend=top-left" / >
< source media = "(prefers-color-scheme: light)" srcset = "https://api.star-history.com/chart?repos=blax-software/laravel-workkit&type=date&legend=top-left" / >
< img alt = "Star History Chart" src = "https://api.star-history.com/chart?repos=blax-software/laravel-workkit&type=date&legend=top-left" / >
< / picture >
< / a >