[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software) # Laravel Workkit [![PHP Version](https://img.shields.io/badge/php-%3E%3D8.0-blue)](https://php.net) [![Laravel](https://img.shields.io/badge/laravel-10.x--13.x-orange)](https://laravel.com) [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) A Laravel collection of helpers and utilities to reduce redundant code over multiple projects. ## 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_.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=` 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 # 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 Star History Chart