Compare commits

..

1 Commits

Author SHA1 Message Date
Fabian Wagner 3dca9978d9 fix(backup): widen mode + fail fast when backup dir isn't writable
mkdir was 0755 — owner-only write. The dir often gets created once at
deploy time as root or whoever ran the first artisan command, then
www-data tries to write to it at runtime and bash redirects fail with
"Permission denied" mid-pipeline (after mysqldump has already started
streaming, leaving a 0-byte .enc behind).

- mkdir(0775) so group writes too; ensure-group-write is the typical
  pattern for shared deploy/runtime users.
- Best-effort chmod 0775 on existing dirs to repair narrow modes when
  we own the path.
- is_writable() pre-flight before kicking off any pipeline. Throws a
  RuntimeException with the exact `chown` + `chmod` command to run,
  including the discovered owner and the current process user — so the
  fix is one paste away instead of grepping man pages.

This pairs with docker-laravel's start-container change that pre-creates
storage/backups/ owned by www-data on every boot. Either layer alone is
enough; both together means the failure mode disappears whether the
deployment uses our image or not.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 10:05:46 +02:00
20 changed files with 331 additions and 4169 deletions

View File

@ -1,85 +0,0 @@
# Changelog
All notable changes to `laravel-workkit` are documented here. This project
follows [Keep a Changelog](https://keepachangelog.com/) and keeps every release
backward-compatible with the previous minor (see
`PRINCIPLES/laravel-composer-packages.md` § Backward compatibility).
## Unreleased
### Added
- **`workkit:stats`** (+ `StatsService`) — terminal "admin overview": per-Eloquent-model
row counts (fast `information_schema` estimate on MySQL, `--exact` for `COUNT(*)`),
largest tables by size, total database size, and a compact queue + backup summary.
Models are auto-discovered from `app/Models` or `config('workkit.stats.models')`.
`--json` for machine consumption; degrades gracefully on non-MySQL drivers.
- **`workkit:queue:health`** (+ `QueueHealthService`) — per-queue depth / due / delayed /
reserved counts and oldest-due-job age for the database queue driver, failed-jobs
totals (+ last *N* hours), and STALLED-queue detection (due work past
`max_age_minutes` with nothing reserved → worker likely down). Exits non-zero on any
breach for cron/monitoring; thresholds under `config('workkit.queue.*')` with per-queue
depth overrides. `QueueHealthService::evaluate()` is a pure, unit-testable function.
- New config blocks `workkit.stats.*` and `workkit.queue.*` (read with explicit
code-side defaults, so consumers who published an older config are unaffected).
### Added — backups
- **`workkit:db:restore --fresh`** — drops every table + view in the target
before importing, the only reliable fix for a restore failing with
`errno 150` / error `3780` against a dirty or partially-migrated schema. The
wipe is performed via the **same** `mysql` client and database the import
uses (built from the connection's `$cfg`, scoped to `DATABASE()`), so a
`url` DSN / `unix_socket` / read-write split can never make the wipe and the
import target different databases.
- **Pre-restore safety snapshot**`--fresh` first takes *and verifies* a
snapshot of the current database (named `*.pre-restore.sql.xz.enc`) and
aborts before dropping anything if it can't be written; opt out with
`--no-safety-backup`. On a post-wipe import failure the command prints the
exact `workkit:db:restore --file=<snapshot>` recovery command.
- **`workkit:db:verify`** (+ `workkit:db:backup --verify`) — proves a backup
decrypts with this host's `APP_KEY` and is a complete, non-truncated xz
stream, without touching the database; flags a missing mysqldump completion
marker and compares the on-disk sha256 against the metadata sidecar.
`--all` checks every backup.
- **Metadata sidecar**`workkit:db:backup` writes a best-effort
`<file>.meta.json` (size, sha256, created_at, cipher params) next to each
backup. Never fails a good backup if it can't be written.
- **`workkit:db:prune-backups --keep-min`** (config
`backup.retention_min_keep`, default 5) — always keeps the N newest backups
regardless of age, so an aggressive `--days` can never leave zero recovery
points. Prune now also removes a backup's sidecar alongside it and sweeps
orphaned sidecars.
- **Restore safeguards** — the source backup is verified before any
destructive step; a dirty (non-empty) target is reported with a `--fresh`
hint; an import that incompatibly fails prints actionable guidance; a
non-trivial backup that imports zero tables is flagged as a possible silent
failure. The confirm line now shows the backup size.
- New public `BackupService` helpers (all additive): `verify()`,
`dropAllTables()`, `countTables()`, `estimateDatabaseBytes()`,
`backupFiles()`, `restoreCandidates()`, `metaPathFor()`, `humanBytes()`,
`runResult()`.
### Changed
- **`BackupService::decryptDecompressImport()` now prepends
`SET FOREIGN_KEY_CHECKS=0;`** to the import stream so a hand-rolled dump
lacking mysqldump's own header still imports into an empty database
regardless of table order. Harmless for mysqldump output (which sets/restores
its own session vars), and the whole import is one short-lived session so the
relaxed check never leaks. This does **not** rescue a restore onto a dirty
schema with incompatible column types — use `--fresh` for that.
- File selection in `restore`/`prune` now ignores `*.meta.json` sidecars while
still finding custom `--out`-named backups (it denies the sidecar suffix
rather than allow-listing one extension). The `restore` auto-pick also skips
`*.pre-restore.*` snapshots so a recovery snapshot of a known-bad database is
never restored by accident.
### Notes
- Existing `BackupService` method signatures and all config keys are unchanged;
new behavior is additive or confined to the new `--fresh` path. New nested
config keys are read with explicit code-side defaults, so a consumer who
published an older `config/workkit.php` is unaffected.
- The README is now backup-documented and brought up to the standard Blax OSS
package skeleton (badges, feature list, Star History).

View File

@ -1,761 +0,0 @@
# Blax Software — Laravel Dockerization & Deployment Principles
This document is the single source of truth for how every Blax Software
Laravel **application** (not package) is containerized and deployed. It is
the application-side companion to
[[laravel-composer-packages]] — packages describe a library's contract,
this describes how a host app actually runs in dev and in production.
Two flavours of `deploy.sh` exist in the fleet:
- A **canonical** flavour — minimal, do-the-right-thing script that covers
composer-install / migrate / cache / restart workers. Use this by
default.
- An **extended** flavour — same script plus optional version tagging,
pre-deploy encrypted DB backups, and a hard WebSocket restart with
liveness verification. Adopt it when the app needs those features.
If you are creating a new Laravel app, copy these conventions verbatim. If
an app deviates, justify it inline (README) and ideally fold the
improvement back here.
---
## 1. Use the shared `blaxsoftware/laravel` image — never a custom Dockerfile
Every Laravel app runs on a tag of `blaxsoftware/laravel`:
```yaml
services:
app:
image: blaxsoftware/laravel:laravel13-php8.4
```
The image bakes in nginx + php-fpm + supervisor, the PHP extensions every
Blax project needs, composer, and the supervisor scaffolding that the
`ENABLE_*` flags below switch on. There is **no per-project `Dockerfile`**
and no `build:` block in compose — the image is the contract, every app
gets the same base, upgrades happen by bumping the tag.
### Tag scheme
```
blaxsoftware/laravel:laravel<MAJOR>-php<X.Y>
```
Examples that exist today:
| Tag | Use for |
|---|---|
| `laravel13-php8.4` | New apps (Laravel 13, PHP 8.4) |
| `laravel12-php8.4` | Apps on Laravel 12 |
| `laravel11-php8.4` | Apps on Laravel 11 |
| `laravel10-php8.3` | Legacy Laravel 10 apps |
Always pin a specific framework + PHP tag (`laravel13-php8.4`), never
`latest`. The matrix is rebuilt centrally; bump the tag in your
`docker-compose.yml` when you upgrade Laravel.
### Forbidden
- A `Dockerfile` in the project root that `FROM`s `php:8.x-fpm` and
re-installs nginx/supervisor/extensions. That re-invents the image, drifts
away from the fleet, and breaks the "bump one tag, all apps stay current"
story. A per-project `Dockerfile` plus `build: ./docker` block is the
anti-pattern; migrate any app still doing this (see §9).
- `build:` keys in compose pointing at a per-project Dockerfile.
- Installing extra system packages via container-side `apt` in deploy
scripts. If you need an extension that isn't in the base image, raise it
on `blaxsoftware/laravel` and bake it into the image.
### Why
One image, one supervisor config, one PHP build. Every app upgrade is a
tag change, every fleet-wide CVE patch is one image rebuild. Custom
Dockerfiles in each repo guarantee they will drift — different PHP minor
versions, different ext list, different OS base. We stopped doing that.
---
## 2. Process management via `ENABLE_*` env flags
The image's supervisor reads a small set of env vars at boot and starts
the corresponding workers. **Don't write your own supervisor entries for
these** — flip the flag and the image does it:
| Env var | Effect | Default |
|---|---|---|
| `ENABLE_QUEUE` | Starts `php artisan queue:work` under supervisor | off |
| `ENABLE_SCHEDULER` | Runs `php artisan schedule:run` every minute | off |
| `ENABLE_HORIZON` | Starts `php artisan horizon` (use instead of `ENABLE_QUEUE` when you've adopted Horizon) | off |
| `ENABLE_LARAVEL_PERMS` | On boot, chowns `storage/` + `bootstrap/cache/` to `www-data` so Laravel can write logs/sessions/caches | off |
| `PUSHER_PORT` | Port for the websockets server when you publish a custom supervisor entry that needs it (see §3) | unset |
Canonical app block:
```yaml
app:
image: blaxsoftware/laravel:laravel13-php8.4
environment:
ENABLE_QUEUE: "true"
ENABLE_SCHEDULER: "true"
ENABLE_HORIZON: "false"
ENABLE_LARAVEL_PERMS: "1"
PUSHER_PORT: "6001"
```
Set `ENABLE_HORIZON: "true"` **instead of** `ENABLE_QUEUE` — never both.
---
## 3. Custom supervisor configs: WebSocket server
The one process the image does NOT start for you is your app's WebSocket
server (it lives in your app, not the base image). Drop the config in
`docker/supervisor/` and mount it to `/etc/supervisor/custom.d`:
```yaml
app:
volumes:
- ./:/var/www/html
- ./docker/supervisor:/etc/supervisor/custom.d
```
`docker/supervisor/websocket.conf`:
```ini
[program:websocket]
command=/usr/local/bin/php -d variables_order=EGPCS /var/www/html/artisan websockets:serve --host=0.0.0.0 --port=6001
autostart=true
autorestart=true
user=www-data
priority=30
startsecs=5
startretries=100
stopsignal=TERM
stopwaitsecs=15
stdout_logfile=/proc/1/fd/1
stdout_logfile_maxbytes=0
stderr_logfile=/proc/1/fd/2
stderr_logfile_maxbytes=0
```
Notes:
- `user=www-data` is non-negotiable — Laravel's storage permissions are
set up for `www-data` by `ENABLE_LARAVEL_PERMS`.
- Logs go to `/proc/1/fd/{1,2}` so `docker logs app` shows the WS output
inline with nginx/php-fpm.
- `priority=30` runs the WS after php-fpm/nginx (priority 10/20) so the
app routes that the WS auth callback hits are already serving.
Anything else you need (custom workers, one-off daemons) follows the same
pattern: one file per program in `docker/supervisor/`.
---
## 4. Compose file split: `docker-compose.yml` (prod-shape) + override (local-only)
Two compose files, each with one job:
- **`docker-compose.yml`** — the production deployment shape. HTTP only,
no port exposes, traefik on the plain `web` entrypoint. Committed.
This is the *only* file the deploy script loads.
- **`docker-compose.override.yml`** — local-dev additions: mkcert TLS labels
on traefik's `websecure` entrypoint, a `mysql ports: 3306` expose so you
can connect with TablePlus. Auto-loaded by `docker compose up`, **not**
loaded by `docker compose -f docker-compose.yml …`. Committed.
The production deploy script always uses `-f docker-compose.yml` explicitly
to ensure the override is skipped on the server. Local devs run `docker
compose up` (no `-f`) and get both files merged automatically.
> Alternative pattern: a third file `docker-compose.local.yml` exists in
> place of the auto-loaded override, and developers opt in via
> `COMPOSE_FILE` env or explicit `-f` flags. This works but is
> friction-heavy; **prefer the override.yml auto-load pattern** unless you
> have a specific reason (e.g. CI needs a clean compose without dev labels).
### Reference: `docker-compose.yml`
```yaml
networks:
web:
external: true
internal:
driver: bridge
# YAML anchor — all production traefik labels declared once, reused across
# services. Each Host() rule accepts BOTH the prod hostname AND the local
# *.localhost.at dev alias, so this file works on both sides; the
# override.yml adds the websecure (mkcert TLS) routers on top for local.
#
# In production the app sees plain HTTP only — upstream nginx terminates
# TLS and proxies through to traefik, and traefik's web entrypoint owns
# the HTTP→HTTPS redirect logic. So declaring entrypoints: web here is
# sufficient end-to-end on the prod host.
x-traefik-labels: &traefik-labels
traefik.enable: "true"
traefik.docker.network: "web"
# App HTTP
traefik.http.routers.<app>.rule: "Host(`<prod-hostname>`) || Host(`<app>.localhost.at`)"
traefik.http.routers.<app>.entrypoints: "web"
traefik.http.routers.<app>.service: "<app>-http"
traefik.http.services.<app>-http.loadbalancer.server.port: "80"
# App WebSocket
traefik.http.routers.<app>-ws.rule: "Host(`ws-<prod-hostname>`) || Host(`ws-<app>.localhost.at`)"
traefik.http.routers.<app>-ws.entrypoints: "web"
traefik.http.routers.<app>-ws.service: "<app>-ws"
traefik.http.services.<app>-ws.loadbalancer.server.port: "6001"
services:
app:
image: blaxsoftware/laravel:laravel13-php8.4
container_name: <app>-app
restart: unless-stopped
working_dir: /var/www/html
volumes:
- ./:/var/www/html
- ./docker/supervisor:/etc/supervisor/custom.d
environment:
ENABLE_QUEUE: "true"
ENABLE_SCHEDULER: "true"
ENABLE_HORIZON: "false"
ENABLE_LARAVEL_PERMS: "1"
PUSHER_PORT: "6001"
networks: [web, internal]
depends_on:
mysql: { condition: service_healthy }
redis: { condition: service_healthy }
labels:
<<: *traefik-labels
mysql:
image: mysql:8.0
container_name: <app>-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: "${DB_PASSWORD:-secret}"
MYSQL_DATABASE: "${DB_DATABASE:-<app>}"
volumes:
- ./docker-data/mysql:/var/lib/mysql
networks: [internal]
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_PASSWORD:-secret}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: <app>-redis
restart: unless-stopped
volumes:
- ./docker-data/redis:/data
networks: [internal]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
```
### Reference: `docker-compose.override.yml` (local dev only)
```yaml
# Local-dev only — mkcert TLS on traefik's websecure entrypoint, mysql
# port expose. Auto-loaded by `docker compose up`; deploy.sh uses
# `-f docker-compose.yml` explicitly so this file is ignored in prod.
services:
app:
labels:
traefik.http.routers.<app>-tls.rule: "Host(`<app>.localhost.at`)"
traefik.http.routers.<app>-tls.entrypoints: "websecure"
traefik.http.routers.<app>-tls.tls: "true"
traefik.http.routers.<app>-tls.service: "<app>-https"
traefik.http.services.<app>-https.loadbalancer.server.port: "80"
traefik.http.routers.<app>-wss.rule: "Host(`ws-<app>.localhost.at`)"
traefik.http.routers.<app>-wss.entrypoints: "websecure"
traefik.http.routers.<app>-wss.tls: "true"
traefik.http.routers.<app>-wss.service: "<app>-wss"
traefik.http.services.<app>-wss.loadbalancer.server.port: "6001"
mysql:
ports:
- "3387:3306" # pick a unique host port per app to avoid collisions
```
---
## 5. Traefik conventions, hostnames, and TLS
**Routing fronts every app.** No app ever publishes 80/443 directly —
traefik does, on the shared `web` Docker network. Apps just declare
labels.
### The shared `web` network
`web` is an externally-created bridge network on each host (dev laptop +
prod server) where traefik listens for containers with
`traefik.enable=true`. Every app's `app` service joins it; `mysql` /
`redis` / other backing services stay on the per-app `internal` network.
If `web` doesn't exist on a fresh box, create it once:
```bash
docker network create web
```
### Hostname conventions
- **Local development** uses subdomains of `localhost.at`. The domain has
a wildcard A record pointing at `127.0.0.1`, so anything like
`<app>.localhost.at` or `ws-<app>.localhost.at` resolves locally
without `/etc/hosts` edits. Devs install a mkcert-signed wildcard cert
for `*.localhost.at` once and traefik terminates TLS on the
`websecure` entrypoint using it.
- **Production hostnames are app-defined** in the `Host()` rule on each
router — typically a subdomain of `blax.at` (e.g.
`api-<thing>.blax.at`, `ws-api-<thing>.blax.at`), but the rule is the
only source of truth. Use whatever public hostname the app is
reachable at.
The same router rule accepts both the prod hostname and the local
`localhost.at` alias with an `||`, so one set of labels works in both
environments:
```
Host(`<prod-hostname>`) || Host(`<app>.localhost.at`)
```
### Two entrypoints, two roles
- `web` — plain HTTP, port 80.
- `websecure` — TLS, port 443.
Every router in `docker-compose.yml` declares `entrypoints: web`. The
`websecure` routers live in the override and only matter on a dev box
(see "TLS termination" below for why this is OK in prod).
A third `mobile` entrypoint exists on some hosts for Android-emulator
access (`10.0.2.2`, no HTTPS redirect). Add this only if you genuinely
need emulator traffic; for normal apps the two-entrypoint setup is
enough.
### TLS termination
The TLS termination point is different between environments, and that's
intentional:
- **Local dev**: traefik terminates TLS itself using the mkcert wildcard
for `*.localhost.at`. The `websecure` routers from
`docker-compose.override.yml` carry the `tls: "true"` label and traefik
serves the cert. The `web` entrypoint also exists for the plain-HTTP
alias if you need it.
- **Production**: an upstream nginx terminates TLS using the real
certificate, then proxy-passes plain HTTP to traefik. Traefik in turn
manages the HTTP-to-HTTPS redirect for any client that arrived over
HTTP. The result, viewed from the app container: incoming traffic is
always plain HTTP on port 80 regardless of how the client connected,
and the redirect logic lives in the traefik entrypoint configuration
on the prod host, not in the app's compose file.
The practical consequence for your compose files: `docker-compose.yml`
only declares `web`-entrypoint routers, and that's enough for prod
because the entire `https → http → traefik → app` chain is handled
upstream. `docker-compose.override.yml` adds the `websecure` routers so
that local dev gets working HTTPS without an extra nginx hop.
### Router naming scheme
| Router suffix | What it serves | Where declared |
|---|---|---|
| `<app>` | App HTTP on port 80 | `docker-compose.yml` |
| `<app>-ws` | WebSocket plain on port 6001 | `docker-compose.yml` |
| `<app>-tls` | App HTTPS (mkcert, local only) | `docker-compose.override.yml` |
| `<app>-wss` | WebSocket secure (mkcert, local only) | `docker-compose.override.yml` |
The router *names* must be globally unique across all apps on a host —
that's why each one starts with the app's slug.
### Forbidden
- An app service publishing `ports: [80:80]` or `ports: [443:443]`. Use
traefik labels instead.
- Putting TLS / `websecure` labels in `docker-compose.yml`. TLS in this
file would force traefik to attempt cert handshakes in prod, which is
upstream nginx's job. TLS labels live in the override (local dev) only.
- Hardcoding `loadbalancer.server.port: 6001` for the app's HTTP router.
Port 80 for HTTP, port 6001 for the WS service — don't mix them up.
---
## 6. Persistent data: `./docker-data/` bind mounts, never named volumes
**Rule: any service that needs to keep state between container restarts
gets a bind mount under `./docker-data/<service>/`. Never a named docker
volume.** This applies to mysql, redis, ssh host keys, app uploads,
queue state, every "this dir needs to survive" case — same shape, same
location, no exceptions.
```
docker-data/
mysql/ # mysql:8.0 datadir
redis/ # redis dump.rdb
… # whatever else needs persistence
```
The folder is gitignored (`docker-data/` in `.gitignore`). The deploy
script `mkdir -p`s it on first run so fresh boxes Just Work.
### Why bind mounts, not named volumes
The headline reason: **`docker compose down -v` wipes named volumes**.
That command is in too many people's muscle memory ("nuke the stack and
start clean") for the production datastore to be one accidental keystroke
away from gone. Bind mounts under the repo are immune — `down -v`
doesn't touch them.
The rest of the rationale:
- Trivially backed up — `tar -caf data.tar.xz docker-data/` from the
repo root is the entire prod state.
- Survives container/image churn including accidental
`docker volume prune -af` and `docker system prune --volumes`.
- Discoverable — anyone with the repo can see where the data lives.
- The repo path identifies which app owns the data when you have ten
apps on one host.
- Restoring on a new host is `git clone && rsync docker-data/` — no
per-volume `docker volume create && docker run --rm -v ... tar` dance.
### Counter-pattern (do not do this)
```yaml
# WRONG — named volume; `docker compose down -v` deletes the data.
services:
mysql:
volumes:
- mysql-data:/var/lib/mysql
volumes:
mysql-data:
```
```yaml
# RIGHT — bind mount; survives `down -v`.
services:
mysql:
volumes:
- ./docker-data/mysql:/var/lib/mysql
```
---
## 7. The `deploy.sh` script — canonical pattern
Every app has a `deploy.sh` at the repo root that runs end-to-end deploys.
Use the **canonical** flavour below as the default; use the **extended**
flavour when you also need version tagging, a pre-deploy DB backup, or a
hard WebSocket restart with liveness verification (see §7.3).
### 7.1 Canonical deploy.sh
```bash
#!/bin/bash
set -e
SELF="$0"
COMPOSE_CMD=(docker compose -f docker-compose.yml)
# ── Parse arguments ──────────────────────────────────────────────────
FLAG=""
for arg in "$@"; do
case "$arg" in
--after-pull|--force-recreate)
FLAG="$arg" ;;
*) ;;
esac
done
echo "==> Starting deploy script..."
# ── Self-update check (first run only) ───────────────────────────────
if [ "$FLAG" != "--after-pull" ] && [ "$FLAG" != "--force-recreate" ]; then
echo "==> Checking for updates..."
ORIGINAL_HASH=$(md5sum "$SELF" | cut -d' ' -f1)
git pull --rebase --stat
UPDATED_HASH=$(md5sum "$SELF" | cut -d' ' -f1)
PASSTHROUGH_ARGS=("--after-pull")
if [ "$ORIGINAL_HASH" != "$UPDATED_HASH" ]; then
echo "==> Script updated — re-running..."
echo
"$SELF" "${PASSTHROUGH_ARGS[@]}"
exit $?
fi
echo "==> No script changes detected."
echo
"$SELF" "${PASSTHROUGH_ARGS[@]}"
exit $?
fi
echo "==> Running deployment steps..."
# ── Deployment steps ─────────────────────────────────────────────────
REPO_UID=$(stat -c '%u' . 2>/dev/null || id -u)
REPO_GID=$(stat -c '%g' . 2>/dev/null || id -g)
DEPLOY_UID=${DEPLOY_UID:-$REPO_UID}
DEPLOY_GID=${DEPLOY_GID:-$REPO_GID}
APP_EXEC=("${COMPOSE_CMD[@]}" exec -T -u "${DEPLOY_UID}:${DEPLOY_GID}" app)
echo "==> Using production compose file only (docker-compose.yml)."
echo "==> Deployment user in app container: ${DEPLOY_UID}:${DEPLOY_GID}"
echo "==> Preparing writable directories..."
mkdir -p \
vendor \
bootstrap/cache \
storage/framework/cache \
storage/framework/sessions \
storage/framework/views \
storage/logs \
docker-data/mysql \
docker-data/redis
if [ "$(id -u)" -eq 0 ]; then
echo "==> Fixing ownership on writable directories (root mode)..."
chown -R "${DEPLOY_UID}:${DEPLOY_GID}" \
vendor \
bootstrap/cache \
storage \
docker-data \
2>/dev/null || true
else
echo "==> Skipping host chown (not root)."
fi
echo "==> Ensuring containers are up before exec..."
"${COMPOSE_CMD[@]}" up -d mysql redis
"${COMPOSE_CMD[@]}" up -d --no-deps app
echo "==> Preparing git/composer environment in container..."
"${APP_EXEC[@]}" bash -c "export HOME=/tmp; git config --global --add safe.directory /var/www/html || true"
"${APP_EXEC[@]}" bash -c "mkdir -p /var/www/html/vendor /var/www/html/bootstrap/cache /var/www/html/storage"
echo "==> Installing composer dependencies..."
"${APP_EXEC[@]}" bash -c \
"export HOME=/tmp; git config --global --add safe.directory /var/www/html || true; COMPOSER_HOME=/tmp/composer-home COMPOSER_CACHE_DIR=/tmp/composer-cache XDG_CACHE_HOME=/tmp composer install --no-interaction --prefer-dist --optimize-autoloader --no-dev"
echo "==> Running migrations..."
"${APP_EXEC[@]}" bash -c "php artisan migrate --force"
echo "==> Caching config/routes/views..."
"${APP_EXEC[@]}" bash -c "php artisan config:cache && php artisan route:cache && php artisan view:cache"
echo "==> Rebuilding/starting containers..."
if [ "$FLAG" == "--force-recreate" ]; then
"${COMPOSE_CMD[@]}" up -d --force-recreate --build app
else
"${COMPOSE_CMD[@]}" up -d --no-deps --build app
echo "==> Restarting queue worker (picks up new code)..."
"${APP_EXEC[@]}" php artisan queue:restart
echo "==> Sending hard restart signal to WebSocket server..."
"${APP_EXEC[@]}" php artisan websocket:steer restart || true
echo "==> WebSocket will restart within ~5 seconds (supervisor auto-restarts)."
fi
echo "==> Deployment complete!"
```
### 7.2 What each step does (and why)
1. **Self-update via md5sum + `--after-pull`** — the script `git pull`s
the repo, hashes itself before and after, and if the script file
changed, *re-executes its new copy* with `--after-pull`. This means
improvements to deploy.sh are picked up on the next deploy without a
"remember to re-run after the pull" footgun. The new copy gets the
flag so it skips the pull step.
2. **Production compose only** — `COMPOSE_CMD=(docker compose -f
docker-compose.yml)`. No override.yml on prod.
3. **UID/GID detection** — uses the repo owner's UID/GID by default
(overridable via `DEPLOY_UID`/`DEPLOY_GID` env). Container processes
that touch the bind-mounted source run as that UID so file ownership
stays consistent. Prevents the classic "host can't edit a file that
container wrote as UID 33" problem.
4. **`mkdir -p` writable dirs** — ensures the bind-mount points exist
*before* the container starts. Docker would otherwise create them as
root.
5. **Conditional chown** — only runs when the script is invoked as root
(e.g. by the deploy webhook). When a developer runs it manually as
their own user, the chown is skipped (it would fail anyway).
6. **Up `mysql` + `redis` first, then `app`**`--no-deps` on `app`
means we don't restart the DB just because the app code changed.
7. **`composer install --no-dev`** inside the container with
`HOME=/tmp` and isolated composer dirs — avoids touching anything
under `/var/www/html/.composer` (which would be on the bind-mounted
host filesystem) and avoids "dubious ownership" git errors.
8. **`migrate --force`** — `--force` is required because the artisan
prompt detects non-TTY and would otherwise abort.
9. **`config:cache && route:cache && view:cache`** — production
optimizations. Note these run *after* `migrate` so any migration-
triggered config change is captured.
10. **`--no-deps --build app`** — rebuild the app container but don't
touch mysql/redis. Switches the running container atomically.
11. **`queue:restart`** — sets the `illuminate:queue:restart` cache
timestamp; running workers see it on their next loop and exit, and
supervisor restarts them with the new code.
12. **`websocket:steer restart`** — Blax's `laravel-websockets` package
polls the cache for a restart sentinel. The new WS process picks up
the new code; old clients reconnect automatically.
### 7.3 Optional add-ons (extended flavour)
Add these if the app needs them. They sit between the composer-install
and the rebuild steps:
- **Pre-deploy DB backup** via `php artisan workkit:db:backup` (encrypted
dump to `storage/backups/`). `set -e` means a failed backup aborts the
deploy — we'd rather block than push migrations forward without a
recovery point. Restore via `php artisan workkit:db:restore`.
- **Version tagging**`--patch` / `--minor` / `--major` / `--version=X.Y.Z`
flags bump a `vX.Y.Z` git tag and push it before the deploy steps run.
Also maintains a moving `deploy` tag that always points at the last
successful deploy.
- **WebSocket hard-restart with verify** — `php artisan
websocket:restart-hard` (sends SIGTERM directly to the
`websockets:serve` PID, escalates to SIGKILL if needed) followed by a
`pgrep` check that the process came back. Fails the deploy loudly if
the WS server isn't running afterwards.
Don't adopt these unless the app has the matching infrastructure — the
`workkit:db:backup` artisan command (from `blax-software/laravel-workkit`)
and the `websocket:restart-hard` command (from
`blax-software/laravel-websockets`).
### 7.4 Calling deploy.sh from anywhere
The first execution does `git pull` and re-execs itself, so the script
doesn't care which commit it starts on — old or new. Just:
```bash
ssh prod cd /srv/<app> && bash deploy.sh
```
Or wire it to a webhook / CI job. The `--after-pull` flag is internal —
don't pass it manually.
---
## 8. Local dev: same compose, traefik + mkcert
Once a developer has a single host-level setup in place (traefik on the
`web` network, mkcert wildcard for `*.localhost.at`), every Blax Laravel
app behaves identically:
```bash
# First time on a new repo
docker compose up -d # picks up docker-compose.yml + override.yml
docker compose exec app composer install
docker compose exec app php artisan migrate
# Browse at https://<app>.localhost.at
# WebSocket at wss://ws-<app>.localhost.at
```
`docker compose up` auto-loads the override.yml because the file exists
in the repo root. No flags needed.
### Updating to a newer image
Bump the tag in `docker-compose.yml` (e.g. `laravel12-php8.4 →
laravel13-php8.4`), then:
```bash
docker compose pull app
docker compose up -d --force-recreate --build app
```
No rebuild step, no Dockerfile edits — the image is the only source of
binary changes.
---
## 9. Migration path: existing apps still on a custom Dockerfile
If you find an app with a per-project `Dockerfile` and a `build:` block,
migrate it to the shared image in this order:
1. Replace the `build: ./docker` block with `image:
blaxsoftware/laravel:laravel<N>-php<X.Y>`.
2. Delete the `Dockerfile` and the `./docker/Dockerfile` build directory
(keep `./docker/supervisor/` — the supervisor configs still apply).
3. Add the `ENABLE_*` env vars to the `app` service (§2). Remove any
supervisor entries for queue/scheduler that are now redundant.
4. Split the existing compose into `docker-compose.yml` (prod-shape) +
`docker-compose.override.yml` (local TLS + port exposes). Move
`websecure` / `wss` routers into the override.
5. Replace the existing deploy script with the canonical one from §7.1.
6. Move data volumes onto `./docker-data/*` bind mounts (§6) if they
aren't already. Use `docker cp` from the old volume into the new
bind-mount path before tearing down.
7. `docker compose down && docker compose up -d --force-recreate`.
The data survives because mysql/redis storage is now a bind-mount on
disk, not a named volume tied to the old image.
---
## Checklist for a new Blax Laravel app
- [ ] App service uses `image: blaxsoftware/laravel:laravel<N>-php<X.Y>`
— no `build:`, no per-project Dockerfile.
- [ ] `ENABLE_QUEUE`, `ENABLE_SCHEDULER`, `ENABLE_LARAVEL_PERMS` are set
explicitly on the `app` service (true/false strings).
- [ ] WebSocket process declared in `docker/supervisor/websocket.conf`,
mounted at `/etc/supervisor/custom.d`. No supervisor entries
duplicated for queue/scheduler/horizon (the image owns those).
- [ ] `docker-compose.yml` describes the **production** shape — HTTP only,
traefik labels on `web` entrypoint, no port exposes, mysql/redis
data bind-mounted under `./docker-data/`.
- [ ] `docker-compose.override.yml` adds **local-only** TLS routers on
`websecure` and any host-port exposes (e.g. `mysql 3306`). No
duplicated production labels.
- [ ] Traefik labels declared once via `&traefik-labels` YAML anchor;
each `Host()` rule accepts both the prod hostname AND the
`<app>.localhost.at` dev alias.
- [ ] External `web` network referenced for traefik traffic; internal
`internal` bridge network for mysql/redis. Backing services are
NOT on `web`.
- [ ] `docker-data/` is in `.gitignore`. The deploy script `mkdir -p`s
it on first run.
- [ ] `deploy.sh` is the canonical script from §7.1 plus any extended
add-ons from §7.3 you actually need.
- [ ] deploy.sh self-update via md5sum + `--after-pull` is intact —
don't strip it.
- [ ] deploy.sh runs `composer install --no-dev`, `migrate --force`,
`config:cache && route:cache && view:cache`, then `queue:restart`
and `websocket:steer restart`.
- [ ] No app service publishes ports 80/443. Routing goes through
traefik labels exclusively.
- [ ] Bumping Laravel version is a one-line tag change in
`docker-compose.yml` + `docker compose pull && up -d
--force-recreate --build app` — never a Dockerfile edit.

View File

@ -1,749 +0,0 @@
# Blax Software — Laravel Composer Package Principles
This document is the single source of truth for how every Blax Software
Laravel composer package (open-source or internal) is built. It exists so
that any consumer who installs one of our packages can rely on a
predictable shape, and so that any maintainer who jumps between packages
finds the same patterns.
If you are creating a new package, copy these conventions verbatim. If a
package deviates, the deviation must be justified inline in that package
(`README.md` or service provider docblock) and ideally lifted back into
this document.
---
## 1. Migrations: hybrid auto-load + publishable
Every package ships migrations as **real timestamped `.php` files** living
in `database/migrations/`. They are NOT `.stub` files. The service provider
both auto-loads them AND offers them for publishing.
This gives consumers the best of both worlds:
- **Plug-and-play**: `composer require …` + `php artisan migrate` works on
a fresh install. No `vendor:publish` step needed for the schema baseline.
- **Future updates**: when the package ships new additive migrations
(added columns, new tables, indexes, fixups), the consumer just runs
`composer update && php artisan migrate` — the new migration auto-loads
from `vendor/` and the migrator picks it up.
- **Escape hatch**: consumers who want to customise the schema (different
ID types, multi-tenant prefixes, extra columns) can publish the
migrations and disable auto-load.
### Pattern (canonical — laravel-roles / laravel-shop)
**File layout**
```
database/migrations/
2025_01_01_000001_create_blax_<package>_tables.php
2025_01_01_000002_<additive_migration>.php
2026_04_26_000001_<later_additive_migration>.php
```
Use the package's first-release date as the timestamp prefix for the
baseline (`2025_01_01_000001_…`) so it sorts before anything a consumer
already has. Each subsequent migration gets its own real timestamp.
**Service provider** (`<Package>ServiceProvider.php`)
```php
public function boot(): void
{
$this->offerPublishing();
$this->registerMigrations();
// …
}
/**
* Auto-load the package's migrations so fresh installs work without
* publishing. Disabled via `<package>.run_migrations = false` for
* projects that prefer to publish + manage migrations themselves.
*/
protected function registerMigrations(): void
{
if (! config('<package>.run_migrations', true)) {
return;
}
$this->loadMigrationsFrom(__DIR__ . '/../database/migrations');
}
/**
* Publishing preserves the SOURCE filename so that any migration
* already run via auto-load is marked as run for the published copy
* too — no duplicate execution.
*/
protected function offerPublishing(): void
{
if (! $this->app->runningInConsole()) {
return;
}
$this->publishes([
__DIR__ . '/../config/<package>.php' => $this->app->configPath('<package>.php'),
], '<package>-config');
$migrationsPath = __DIR__ . '/../database/migrations';
$publishMap = [];
foreach (glob($migrationsPath . '/*.php') as $sourcePath) {
$publishMap[$sourcePath] = $this->app->databasePath('migrations/' . basename($sourcePath));
}
$this->publishes($publishMap, '<package>-migrations');
}
```
**Config key**
```php
// config/<package>.php
return [
/*
* Whether the package should auto-run its migrations. See
* laravel-workkit/PRINCIPLES/laravel-composer-packages.md.
*/
'run_migrations' => true,
// …
];
```
### Why the filename-preserving publish is critical
Laravel's `migrations` table records the migration *filename*. If the
published copy has a different filename than the source (e.g. a fresh
`date('Y_m_d_His')` timestamp), Laravel sees it as a brand-new migration
and runs it again, on top of the auto-loaded copy. By copying with
`basename($sourcePath)` we keep the filenames identical, so the migrator
deduplicates correctly.
### Anti-pattern: fresh-timestamp publish
The bug the filename-preserving publish prevents looks like this in a
service provider:
```php
// ❌ Anti-pattern — produces 1050 errors on every consumer
$this->publishes([
__DIR__ . '/../database/migrations/create_blax_files_table.php.stub'
=> $this->getMigrationFileName('create_blax_files_table.php'),
], 'files-migrations');
protected function getMigrationFileName(string $name): string
{
$timestamp = date('Y_m_d_His');
return $this->app->databasePath() . "/migrations/{$timestamp}_{$name}";
}
```
Each `vendor:publish` produces a NEW filename. Combined with auto-load
this guarantees the table gets created twice and the second run dies
with `SQLSTATE[42S01]: 1050 Table 'files' already exists`. The fix is
either (a) `basename($sourcePath)` in the publish map to inherit the
source name, or (b) the `Schema::hasTable()` guards below — preferably
both. Reference fix: [Blax\Files\FilesServiceProvider::offerPublishing()](/home/a6a2f5842/Documents/Repos/laravel-files/src/FilesServiceProvider.php).
### Idempotency requirement
Every migration MUST be safe to run when its tables/columns already
exist. Guard each `Schema::create` with `if (! Schema::hasTable(...))`
and each `Schema::table` column addition with
`if (! Schema::hasColumn(...))`. Reason: in real consumer projects
people *will* end up with both a published copy (with a different
timestamp) and the auto-loaded copy, and we want graceful degradation
instead of fatal errors.
### Workbench schema must mirror the package schema
The workbench `database/migrations/` directory is what your test suite
runs against. It must reflect the SAME schema a consumer would see —
either by:
- **Letting the package auto-load do the work.** Don't reimplement the
package's own `Schema::create` calls in the workbench. The service
provider's `loadMigrationsFrom` fires during test boot too, so the
package's own migrations create the tables in the testbench DB. The
workbench only needs migrations for tables the *consumer* would
provide (`users`, host-app fixture tables like `articles`).
- **Or, if the workbench duplicates the package schema for isolation,
keeping it in lockstep with model changes.** When `Filable` switched
to `HasUuids`, the workbench `filables` table needed the matching
`uuid('id')`. Skipping the workbench update means the test suite
silently rots — 39 tests went red in laravel-files for ~5 weeks
before anyone noticed, because nothing in CI was screaming about
the model/schema mismatch.
Pick the first option for new packages. It's less code and
self-consistent: a passing test suite proves the consumer's install
flow works.
### Deviation: laravel-addresses
`laravel-addresses` keeps the original `create_blax_address_tables.php.stub`
as a publish-only stub because some downstream apps already published a
heavily-customised version (UUID PKs, extra columns) and we cannot safely
re-run the baseline against them. *Additive* migrations there still
follow this principle — plain `.php` files, auto-loaded. New packages
should default to the full hybrid (laravel-roles style); only use the
laravel-addresses split if you have an existing customisation problem to
work around.
---
## 2. README structure (open-source packages)
Every Blax Software OSS package README has the **same four mandatory
anchors** and the same final closer. Between them the package author is
free to grow the README to whatever depth the feature surface needs.
### The skeleton
| # | Section | Status |
|---|---|---|
| 1 | OSS banner (linked from laravel-workkit) | **Mandatory** |
| 2 | Title + badges below | **Mandatory** |
| 3 | Emoji feature list of what the package provides | **Mandatory** |
| 4 | Quickstart (install + minimum viable usage) | Suggested |
| 5 | Quick configuration overview of features | Suggested |
| 6 | Anything else (advanced usage, testing, security, credits, license, changelog, etc.) | Free-form |
| 7 | Star History | **Mandatory** |
The order matters — consumers skim top-to-bottom. The four mandatory
items (1, 2, 3, 7) bookend every README and make every Blax repo feel
familiar within two seconds. Between section 3 and 7 the author has
total freedom: a tiny package may go banner → title+badges → features →
quickstart → star history and stop; a large one (see laravel-mail) can
have 15 sections of advanced material in between.
### The canonical skeleton, fleshed out
```markdown
<!-- 1. OSS banner — mandatory, always first, no blank line before the H1 -->
[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software)
<!-- 2. Title + badges — mandatory; title-case, no "Package" suffix -->
# <Title>
<!-- Pick badges that are common in this repo's stack — see "Badges" below -->
[![PHP Version](https://img.shields.io/badge/php-%5E8.2-blue)](https://php.net)
[![Laravel](https://img.shields.io/badge/laravel-10.x--13.x-orange)](https://laravel.com)
<One-sentence description of what the package does the elevator pitch.>
<!-- 3. Emoji feature list — mandatory -->
## Features
- 🛍️ **Headline feature** — short benefit-oriented line
- 💰 **Next feature** — what it gives the consumer
- 📦 **…** — keep each item one line; emoji + bold short title + benefit
- 🎯 …
<!-- 4. Quickstart — suggested -->
## Quick Start
```bash
composer require blax-software/<repo>
php artisan migrate
```
<The shortest possible "hello world" get a consumer to a working call
in under 30 seconds. Use real model names from the package.>
<!-- 5. Quick configuration overview — suggested -->
## Configuration
<Brief tour of the most useful config knobs (table-name overrides, model
bindings, the run_migrations flag, env vars). Don't repeat the whole
config file — link to `config/<package>.php` in the repo for the rest.>
<!-- 6. Anything else — free-form. Examples below; pick what's relevant. -->
## Advanced Usage / Requirements / Testing / Documentation / Security / Credits / License / Changelog …
<!-- 7. Star History — mandatory, always last -->
## Star History
<a href="https://www.star-history.com/?repos=blax-software%2F<repo>&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=blax-software/<repo>&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=blax-software/<repo>&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=blax-software/<repo>&type=date&legend=top-left" />
</picture>
</a>
```
### Notes on each anchor
1. **OSS banner** — always the very first thing in the file, no blank
line before the H1. Linked back to the blax-software org. The SVG is
served from `laravel-workkit/art/oss-initiative-banner.svg` so all
packages share one source of truth.
2. **Title + badges** — title-case, no package-y suffix ("Laravel Roles",
not "Laravel Roles Package"). Badges sit directly under the H1 (no
intervening prose). See "Badges" below for what badges to use.
3. **Emoji feature list** — this is the section consumers skim hardest.
One bullet per line, format `- <emoji> **<short bold title>** — <one-line
benefit>`. Lead with the most compelling features. laravel-shop is the
gold-standard reference. Don't nest sub-bullets; if you need more
detail, link out to a section further down.
7. **Star History** — the star-history.com embed scoped to this repo,
always the very last thing. Update the repo slug in all four
occurrences.
### Badges (anchor 2 detail)
There is no fixed badge set. **Use the badges that are common in this
repo's stack**:
- Laravel composer package → PHP version, Laravel version, License, and
optionally Packagist version + a Tests CI badge once the workflow
exists.
- Nuxt / Vue project → Node version, framework version, npm version,
build status, etc.
- Minecraft plugin → the relevant ecosystem badges (Spigot/Paper version,
bStats, etc.).
The rule: pick what a visitor from that ecosystem expects to see — not a
fixed prescription. Don't ship a badge that's broken (e.g. a Tests CI
badge pointing at a workflow that doesn't exist yet).
### Forbidden
- A blank line between the OSS banner and the H1.
- "Click here", "More info"-style filler links.
- An "About" section before the features — the one-line description
above the features list is enough.
- Marketing emojis in section headings. The features list is the only
place emojis live.
---
## 3. Cross-cutting principles
These apply to **every** Blax composer package, regardless of stack.
### UUIDs or ULIDs for everything
Primary keys are always sortable, non-sequential identifiers — **either
UUIDv4 or ULID**. Integer auto-increments are forbidden in package
schemas. Foreign keys use `foreignUuid(...)` / `foreignUlid(...)`,
polymorphic relations use `uuidMorphs(...)` / `ulidMorphs(...)`. Pick one
style per package and stick with it; don't mix UUIDs and ULIDs in the
same package.
Why: consumer projects in the Blax fleet are UUID/ULID-based (see
[[blax-laravel-conventions]]). A package that returned a `bigint` PK
would force the host to use `morphs()` instead of `uuidMorphs()` to
attach things to it, breaking the host's schema convention.
### Model bindings via config
Every model the package owns is bound in the service provider through a
`<package>.models.*` config key, e.g.:
```php
// config/<package>.php
'models' => [
'product' => \Blax\Shop\Models\Product::class,
],
// <Package>ServiceProvider::register()
$this->app->bind(
\Blax\Shop\Models\Product::class,
fn ($app) => $app->make($app->config['shop.models.product'])
);
```
This lets a consumer extend the package's model (add casts, scopes,
methods) and rebind via config without forking the package. Every
internal reference inside the package must resolve through the container
(`app(Product::class)`, dependency injection, etc.) — never `new
Product()` or `Product::query()` directly.
Reference implementations: [laravel-roles/src/RolesServiceProvider.php:91-100](/home/a6a2f5842/Documents/Repos/laravel-roles/src/RolesServiceProvider.php#L91-L100),
[laravel-addresses/src/AddressesServiceProvider.php:149-165](/home/a6a2f5842/Documents/Repos/laravel-addresses/src/AddressesServiceProvider.php#L149-L165).
### Backward compatibility
Every release of a Blax package must be backward-compatible with the
previous minor version. Consumers must be able to `composer update` and
keep running without code changes.
Concretely:
- **Schema changes are additive only.** New columns, new tables, new
indexes are fine — but never drop or rename an existing column, never
rename a table, never narrow a type. If you absolutely must, deprecate
first and remove only on a major version bump.
- **Public PHP API is stable.** No removing methods, no renaming
classes, no narrowing parameter types or widening return types in
surprising ways. Add new methods rather than changing signatures.
- **Config keys never disappear.** New keys are fine and get sensible
defaults via `mergeConfigFrom`. Existing keys keep working forever —
if they become obsolete, the package ignores them, doesn't error.
- **Events, traits, contracts** carry the same stability guarantee as
public methods.
Why: every internal Blax project pins package versions as `dev-master`
(see [[blax-laravel-conventions]]). A breaking change to a package
breaks every project on the next `composer update`. Treat every push to
`master` as a potentially-shipped release.
### Naming: composer name, PHP namespace, README title
These three labels live in different files but tell the same story —
keep them aligned.
- **Composer package name**`blax-software/laravel-<name>` for Laravel
composer packages. Universal across the fleet (laravel-roles,
laravel-shop, laravel-addresses, laravel-files, laravel-mail,
laravel-websockets, laravel-workkit). For non-Laravel packages drop
the `laravel-` prefix and use the relevant ecosystem prefix.
- **PHP namespace**`Blax\<PackageName>` (e.g. `Blax\Shop`,
`Blax\Roles`, `Blax\Addresses`). The *original* intent was the longer
`BlaxSoftware\Laravel<PackageName>` form, but in practice all but one
package settled on the short `Blax\<PackageName>` form, so that's the
working standard for new packages. The one outlier
(`BlaxSoftware\LaravelWebSockets`) is grandfathered — don't migrate
it.
- **README H1 title** — just the nice human-readable name of the
package. If it's a Laravel package, prefix with `Laravel`. **No
"Package" suffix.** So: `# Laravel Shop`, `# Laravel Roles`,
`# Laravel Mail`. Not `# Laravel Shop Package`.
### Money in integer cents — never floats
Monetary columns are stored as **integer cents** (or the equivalent smallest
currency unit). Never `decimal`, never `float`. The package's casts mark
them `'integer'`, the migrations declare them `integer` (or
`unsignedBigInteger` for large totals like Stripe's `amount_capturable`).
Why: float arithmetic is lossy in non-obvious ways (`0.1 + 0.2 !== 0.3`).
A `decimal` column avoids the float problem at the storage layer but
re-introduces it the moment a value leaves the DB into PHP. Integers
sidestep both. The formatting step (cents → "€19.99") happens at the
*presentation* boundary — never in the model, the service, or the DB.
Currency is a separate column (`currency`, ISO 4217), never inferred from
the integer.
Reference: `Blax\Shop\Models\ProductPrice::$casts` has `unit_amount`,
`sale_unit_amount`, and tier `unit_amount`/`flat_amount` all cast as
`'integer'`.
### Atomic conditional UPDATEs over `lockForUpdate` dances
When you need to decrement a counter race-safely (stock, balance, available
seats), prefer a single atomic conditional UPDATE over a transaction +
`lockForUpdate` + check + update.
```php
// ✅ Atomic — one statement, race-safe
$affected = static::whereKey($this->getKey())
->where('available_copies', '>=', $quantity)
->update(['available_copies' => DB::raw(
'available_copies - '.(int) $quantity
)]);
return $affected > 0;
// ❌ Transactional dance — three statements, locks the row, more code
DB::transaction(function () use ($id, $quantity) {
$row = static::whereKey($id)->lockForUpdate()->first();
if ($row->available_copies < $quantity) {
throw new NotAvailableException();
}
$row->decrement('available_copies', $quantity);
});
```
The atomic form returns the same race-safety guarantee with no transaction
and no row lock — the database honours the `WHERE` and the `UPDATE`
together. If 0 rows match, you know the constraint was violated and your
caller decides how to translate that into a 422 / exception / fallback.
Why: simpler code, fewer round-trips, no transaction state to manage. The
atomic form also composes better in queue jobs and serverless contexts
where transaction lifetimes are dicey.
Use the transactional form only when you genuinely need multi-row consistency
(e.g. "decrement stock AND insert order line item — both or neither"). In
that case the transaction stays small and only wraps the multi-step work.
### Automatic updates — no user action for migration updates
When a package author ships a new migration, a consumer must be able to
get it by running just:
```bash
composer update
php artisan migrate
```
No `vendor:publish` step. No manual file copying. No "edit your
migration to add this column" instructions in the changelog. The hybrid
migration pattern (section 1) is what makes this work — `loadMigrationsFrom`
picks up new files from `vendor/` automatically, and the additive-only
schema rule above guarantees the new migration won't break existing
data.
This is what separates a "Blax-grade" package from a typical
Laravel-ecosystem package that requires `php artisan
vendor:publish --tag=foo-migrations` after every upgrade.
---
## 4. Subclassable models: every relation declares its foreign key
If your package's model is meant to be subclassed by consumers (a host app's
`Book extends Product`, `Invoice extends Document`, …), **every `hasMany`,
`hasOne`, and `belongsToMany` on that model must declare the foreign key
explicitly**. Don't rely on Eloquent's convention to infer it from the
parent class name.
```php
// ✅ Explicit — survives subclassing
public function stocks(): HasMany
{
return $this->hasMany(
config('shop.models.product_stock', ProductStock::class),
'product_id'
);
}
// ❌ Convention-driven — breaks the moment a consumer extends
public function stocks(): HasMany
{
return $this->hasMany(ProductStock::class);
// When called on Book extends Product, Eloquent guesses `book_id`
// and the relation either errors (no such column) or silently
// returns an empty collection.
}
```
This is the most common way a package "appears to support subclassing" but
silently breaks for consumers. Subclassing is the canonical Laravel
extensibility mechanism — far simpler than wrappers, decorators, or
service rebinding — but a single un-prefixed FK on a hasMany ruins it.
The same rule applies to:
- `hasMany` / `hasOne` — pass `'parent_id'` (or whatever the actual column
is) as the second argument.
- `belongsToMany` — pass the pivot table name and both FK columns
explicitly, since the pivot name is *also* inferred from the class.
- Polymorphic morphs (`morphMany`, `morphTo`) are safe — they use the
`*_type` / `*_id` columns directly, not the class name.
Tests for this principle:
- Spin up a bare subclass in a test fixture (`class SubclassedProduct
extends Product {}`) and assert each relation returns rows. If the FK
was inferred from the subclass name, the assertion fails on the insert
or the select.
Reference: [Blax\Shop\Models\Product::attributes(), actions()](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Models/Product.php), [Blax\Shop\Traits\HasStocks::stocks(), allStocks()](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Traits/HasStocks.php), [tests/Feature/Product/ProductSubclassFkTest.php](/home/a6a2f5842/Documents/Repos/laravel-shop/tests/Feature/Product/ProductSubclassFkTest.php) — the regression test built specifically for this rule.
Why: a Blax package's value is amplified by being trivially extensible.
A library that wants to use `laravel-shop` shouldn't model `Book` next
to `Product`; it should `class Book extends Product` and gain stocks /
prices / categories / actions for free. That only works if the relations
keep pointing at `product_id` regardless of the calling subclass.
---
## 5. Domain data lives in tables, policy knobs live in config
Anything that varies **per-record** belongs in a table. Anything that
applies **app-wide** belongs in config. Don't blur the line.
| Belongs in config | Belongs in a table |
|---|---|
| Default loan duration in weeks | The actual due-date of each loan |
| Maximum extensions allowed | This loan's count of extensions used |
| Whether Stripe is enabled | A product's price |
| Cart expiration window | A cart's expiry timestamp |
| Currency code default | An order's actual currency |
| Whether to auto-publish migrations | What columns a table has |
The wrong answer: storing per-product pricing tiers in
`config('shop.loan.pricing')` — every product has to share one ladder,
host apps can't differentiate, and the data is uneditable through the
admin UI. The right answer is a `product_price_tiers` table with one row
per tier, FK to `product_prices`.
The "config-vs-data" smell test: ask "can two records sensibly disagree
about this value?" If yes, it's data. If no, it's config.
Edge case — **defaults that policy can override**: the default loan
duration sits in config (`shop.loan.default_duration_weeks = 2`) but a
specific borrower might have a 4-week limit (data, on the user record). The
loan creation logic reads config as the floor, then lets per-record data
override. Both layers coexist, neither "wins" — config is the policy,
data is the exception.
Reference: [Blax\Shop\Models\ProductPriceTier](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Models/ProductPriceTier.php) — pricing as data;
[config('shop.loan')](/home/a6a2f5842/Documents/Repos/laravel-shop/config/shop.php) — duration / extension policy as config.
---
## 6. Lifecycle traits split fat models
When a model accumulates 200+ lines of methods around one domain concept
(booking lifecycle, loan lifecycle, audit log, soft archival …), extract
that concept into a **domain-named trait** named after the *concept*, not
the model.
```php
// ✅ Concept-named trait — co-located, importable, separately testable
use HasBookingLifecycle, HasLoanLifecycle;
// ❌ Bag-of-traits with model-derived names — fans out infinitely
use ProductPurchaseScopes, ProductPurchaseMethods, ProductPurchaseHelpers;
```
The good trait names describe what they do (booking lifecycle, loan
lifecycle); the bad ones just describe where they came from
(ProductPurchaseScopes). The first style stays useful when another model
needs the same behavior — `HasLoanLifecycle` could attach to a future
`Subscription` model too. The second is impossible to lift out.
Rules of thumb:
- **One concept per trait.** If you can't describe the trait in one
sentence ("loan extension / return semantics on a purchase row"), it's
doing too much. Split.
- **Unit-test the trait directly.** If the trait can only be tested via
the host model's integration paths, the trait has hidden coupling. The
unit test for a lifecycle trait should be able to spin up a bare model
+ trait and exercise the methods.
- **Co-locate scopes with the methods that use the same domain meta
keys.** A scope reading `meta->returned_at` belongs next to the method
that writes `meta.returned_at`.
- **Don't move the host's `protected $casts` or `$fillable`** into the
trait. Those stay on the model — the trait declares *behavior*, not
*schema*.
Reference: [Blax\Shop\Traits\HasBookingLifecycle](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Traits/HasBookingLifecycle.php), [Blax\Shop\Traits\HasLoanLifecycle](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Traits/HasLoanLifecycle.php) — extracted from `ProductPurchase` so the model declares its data shape and composes its behavior.
---
## 7. API resource translators decouple internal vocabulary from public contracts
Eloquent column names follow the package's internal vocabulary —
e-commerce in `laravel-shop`'s case (`from`, `until`, `amount_paid`,
`purchasable_*`). Direct serialization leaks that vocabulary into every
host app's API and into every external integration. That's a coupling no
host wants.
**The package ships a base `JsonResource` that translates internal names
to domain-flavored names**, with override hooks for the parts a host
inevitably needs to customize.
```php
// In the package — ships the base translator
class PurchaseResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->id,
'item' => $this->resolveItem(),
'loaned_at' => optional($this->from)->toIso8601String(), // ← `from``loaned_at`
'due_at' => optional($this->until)->toIso8601String(), // ← `until``due_at`
'returned_at' => $this->returnedAt(),
'status' => $this->getDomainStatus(), // ← derived
'accrued_cost' => $this->from ? $this->accruedCost() : null,
];
}
// Hook for host apps to point at their own nested resource.
protected function purchasableResource(): ?string { return null; }
}
// In the host app — minimal subclass for domain vocabulary
class LoanResource extends PurchaseResource
{
public function toArray($request): array
{
$payload = parent::toArray($request);
$payload['book'] = $payload['item']; // rename per domain
unset($payload['item']);
return $payload;
}
protected function purchasableResource(): ?string
{
return BookResource::class; // point at app resource
}
}
```
Rules:
- **Never serialize the model directly.** A bare `Resource::make($model)`
with no translation layer ships the package's column names to the
caller — change those names and every consumer breaks. The translator
is the contract.
- **The translator name describes the domain output, not the source
model.** `PurchaseResource` is fine; `ProductPurchaseResource` is fine;
but if the resource is loan-flavored, name it `LoanResource` and have
it translate.
- **Hooks for subclasses are explicit methods, not protected attributes
on the resource.** A `purchasableResource()` method is overridable; a
`$purchasableResource = …` property is one Eloquent quirk away from
not working.
Reference: [Blax\Shop\Http\Resources\PurchaseResource](/home/a6a2f5842/Documents/Repos/laravel-shop/src/Http/Resources/PurchaseResource.php) — the package translator. [App\Http\Resources\LoanResource](/home/a6a2f5842/Documents/Repos/moonshiner-library/app/Http/Resources/LoanResource.php) — the moonshiner library's domain subclass.
Why: it's the only practical way to refactor internal column names without
a breaking-change release. The package can rename `until` to `valid_until`
in a major version, and the translator absorbs the rename — consumers
don't notice.
---
## Checklist for a new Blax Laravel package
- [ ] `database/migrations/` contains real `.php` files (no `.stub`),
timestamped from the package's first-release date.
- [ ] Service provider auto-loads via `loadMigrationsFrom` and offers
filename-preserving publishing.
- [ ] `config/<package>.php` exposes `run_migrations` (default true).
- [ ] Every `Schema::create` is guarded by `hasTable`, every column
addition by `hasColumn`.
- [ ] README has the 4 mandatory anchors in order: OSS banner →
title+badges → emoji feature list → … → Star History.
- [ ] No blank line between the OSS banner and the H1 title.
- [ ] Badges match the repo's stack (no broken badges like a CI badge
pointing at a missing workflow).
- [ ] `composer require` + `php artisan migrate` is the *complete* install
flow for the happy path.
- [ ] Composer name is `blax-software/laravel-<name>` (or stack-equivalent
prefix for non-Laravel packages).
- [ ] PHP namespace is `Blax\<PackageName>`.
- [ ] README H1 is `# Laravel <Name>` (no "Package" suffix) for Laravel
packages, just `# <Name>` otherwise.
- [ ] All primary keys are UUIDs or ULIDs (never integer auto-increments).
- [ ] Every package-owned model is bound via `<package>.models.*` config
and resolved through the container, never `new` or static calls
that bypass binding.
- [ ] The release is backward-compatible: no dropped columns / tables /
methods / config keys, schema changes are additive only.
- [ ] `composer update` + `php artisan migrate` is the *complete* upgrade
flow — no `vendor:publish` step required for migration updates.
- [ ] Money columns are integer cents (never `decimal`, never `float`),
currency is a separate `string(3)` column.
- [ ] Counter-decrement paths use atomic conditional UPDATEs; transactional
`lockForUpdate` only appears where multi-row consistency demands it.
- [ ] Every `hasMany` / `hasOne` / `belongsToMany` on a model that's
intended to be subclassable declares its foreign key explicitly.
A regression test exercises a bare subclass through each relation.
- [ ] Per-record data lives in tables; app-wide policy lives in config.
Pricing tiers, due dates, statuses, currencies → tables. Default
durations, expiration windows, feature flags → config.
- [ ] Domain behavior on models lives in concept-named traits (e.g.
`HasLoanLifecycle`), unit-testable in isolation, never named after
the host model (no `ProductPurchaseScopes`).
- [ ] The package ships a `JsonResource` translator for each model
exposed via API, so host apps subclass for domain vocabulary
without leaking internal column names through the API boundary.

199
README.md
View File

@ -2,203 +2,4 @@
# 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_<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 (09) |
### 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>

View File

@ -6,63 +6,18 @@ return [
| Backup Settings
|--------------------------------------------------------------------------
|
| Used by workkit:db:backup, workkit:db:restore, workkit:db:verify and
| Used by workkit:db:backup, workkit:db:restore and
| workkit:db:prune-backups. The default `path` is storage_path('backups')
| so backups live alongside the rest of the app's storage. `retention_days`
| is the threshold workkit:db:prune-backups uses by default anything
| older than that gets deleted on the next prune run, EXCEPT the
| `retention_min_keep` newest, which are kept regardless of age so prune
| can never leave you with zero recovery points.
|
| NOTE: every command reads these nested keys with an explicit code-side
| default (e.g. config('workkit.backup.retention_min_keep', 5)) because
| mergeConfigFrom does not deep-merge nested arrays a consumer who
| published an older config without a new key still gets a sane value.
| older than that gets deleted on the next prune run.
|
*/
'backup' => [
'path' => env('WORKKIT_BACKUP_PATH'), // null → storage_path('backups')
'retention_days' => (int) env('WORKKIT_BACKUP_RETENTION_DAYS', 30),
// Always keep at least this many newest backups regardless of age.
// 0 disables the floor (prune may then leave zero backups).
'retention_min_keep' => (int) env('WORKKIT_BACKUP_RETENTION_MIN_KEEP', 5),
// xz compression level. Lower = faster + larger output. 3 is a
// good default for SQL dumps (~10× ratio at ~3× the speed of -9).
'xz_level' => (int) env('WORKKIT_BACKUP_XZ_LEVEL', 3),
],
/*
|--------------------------------------------------------------------------
| Stats overview (workkit:stats)
|--------------------------------------------------------------------------
|
| `models` is an explicit list of Eloquent model FQCNs to count. When null
| the command auto-discovers models by scanning `models_path` (default:
| app_path('Models')). Set an explicit list to avoid scanning, or to count
| models that live outside app/Models.
|
*/
'stats' => [
'models' => null, // e.g. [\App\Models\User::class, \App\Models\Order::class]
'models_path' => null, // null → app_path('Models')
],
/*
|--------------------------------------------------------------------------
| Queue health thresholds (workkit:queue:health)
|--------------------------------------------------------------------------
|
| The command exits non-zero when any of these is breached. `max_depth`
| applies per queue; `max_depth_per_queue` overrides it for named queues
| (e.g. an intentionally-slow, rate-limited queue allowed a deeper backlog).
| A queue is flagged STALLED when its oldest *due* job is older than
| `max_age_minutes` while nothing is reserved (worker likely down).
|
*/
'queue' => [
'max_depth' => (int) env('WORKKIT_QUEUE_MAX_DEPTH', 250),
'max_depth_per_queue' => [], // e.g. ['geoip' => 5000]
'max_age_minutes' => (int) env('WORKKIT_QUEUE_MAX_AGE_MINUTES', 15),
'max_failed' => (int) env('WORKKIT_QUEUE_MAX_FAILED', 25),
],
];

View File

@ -1,43 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Workkit\Attributes;
use Attribute;
/**
* Declares per-method pagination policy for a controller action.
*
* `Request::perPage()` reads this attribute via reflection on the resolved
* route action and produces the page size to hand to `->paginate()`:
*
* #[VariablePaginatable] → 25, user can override (1..100)
* #[VariablePaginatable(50)] → 50, user can override (1..100)
* #[VariablePaginatable(10, allowUserOverride: false)] → fixed at 10, no `?per_page=`
* #[VariablePaginatable(50, max: 200)] → 50, user can override (1..200)
*
* Without the attribute, `Request::perPage()` falls back to its $fallback
* argument (15 by default matches Eloquent's model default).
*
* Usage:
*
* #[VariablePaginatable(50)]
* public function index(Request $request): array
* {
* return ResponseService::apiPaginated(
* Book::query()->paginate($request->perPage()),
* BookResource::class,
* );
* }
*/
#[Attribute(Attribute::TARGET_METHOD)]
final class VariablePaginatable
{
public function __construct(
public int $default = 25,
public bool $allowUserOverride = true,
public int $max = 100,
) {
}
}

View File

@ -14,10 +14,6 @@ use RuntimeException;
* is one shell pipe (mysqldump | xz | openssl); PHP holds zero bytes
* of database content in memory regardless of dump size.
*
* A best-effort <file>.meta.json sidecar (size, sha256, created_at,
* cipher params) is written alongside each backup so its integrity can
* later be confirmed and a suspiciously-small dump spotted at a glance.
*
* Output filename:
* storage/backups/db_<connection>_<timestamp>.sql.xz.enc
*/
@ -26,8 +22,7 @@ class BackupCommand extends Command
protected $signature = 'workkit:db:backup
{--connection= : DB connection to back up (defaults to config(database.default))}
{--out= : Custom output path (overrides storage/backups default)}
{--xz-level= : xz compression level 09 (default: 3 fast, ~10× ratio for SQL)}
{--verify : After writing, verify the backup decrypts + is a valid xz stream}';
{--xz-level= : xz compression level 09 (default: 3 fast, ~10× ratio for SQL)}';
protected $description = 'Create a streamed, compressed + APP_KEY-encrypted backup of the configured MySQL database.';
@ -38,19 +33,17 @@ class BackupCommand extends Command
if (! $cfg) {
$this->error("Unknown database connection: {$connection}");
return self::FAILURE;
}
if (($cfg['driver'] ?? null) !== 'mysql') {
$this->error("workkit:db:backup currently supports only MySQL connections (got: {$cfg['driver']}).");
return self::FAILURE;
}
$stamp = date('Y-m-d_H-i-s');
$base = BackupService::backupDirectory();
$outPath = $this->option('out')
?: "{$base}/db_{$connection}_{$stamp}".BackupService::BACKUP_EXT;
?: "{$base}/db_{$connection}_{$stamp}.sql.xz.enc";
$xzLevel = (int) ($this->option('xz-level') ?? config('workkit.backup.xz_level', 3));
@ -65,33 +58,28 @@ class BackupCommand extends Command
BackupService::dumpCompressEncrypt($cfg, $outPath, $xzLevel);
} catch (RuntimeException $e) {
$this->error($e->getMessage());
return self::FAILURE;
}
$elapsed = microtime(true) - $startedAt;
$size = (int) filesize($outPath);
$size = filesize($outPath);
$this->info(sprintf(
'Backup complete in %.1fs: %s (%s)',
$elapsed,
$outPath,
BackupService::humanBytes($size),
self::humanBytes((int) $size),
));
if ($this->option('verify')) {
$r = BackupService::verify($outPath);
if (! $r['integrity_ok']) {
$this->error('Verification FAILED: '.$r['message']);
return self::FAILURE;
}
if (! $r['looks_complete']) {
$this->warn('Verification: '.$r['message']);
} else {
$this->info('Verified: decrypts + valid xz + completion marker present.');
}
}
return self::SUCCESS;
}
private static function humanBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
while ($bytes >= 1024 && $i < count($units) - 1) {
$bytes /= 1024;
$i++;
}
return sprintf('%.2f %s', $bytes, $units[$i]);
}
}

View File

@ -8,15 +8,9 @@ use Blax\Workkit\Services\BackupService;
use Illuminate\Console\Command;
/**
* Drop backup files older than the retention window, while ALWAYS keeping
* the N newest regardless of age (retention_min_keep, default 5) so an
* aggressive --days can never leave you with zero recovery points the
* floor that turns "I deleted my only good backup" into a non-event.
*
* Each backup's .meta.json sidecar is removed alongside it, and orphaned
* sidecars (whose backup is already gone) are swept up too. Pre-restore
* safety snapshots are pruned by the same rules (they're emergency
* artifacts, not long-term backups) but still count toward the keep floor.
* Drop backup files older than the retention window. Defaults to 30
* days; the host can override via --days or by setting
* `workkit.backup.retention_days` in the published config.
*
* Designed to be wired into the scheduler so storage doesn't fill up
* with stale dumps, e.g. Schedule::command('workkit:db:prune-backups')->daily();
@ -25,63 +19,34 @@ class PruneBackupsCommand extends Command
{
protected $signature = 'workkit:db:prune-backups
{--days= : Retention window in days (default: workkit.backup.retention_days, falling back to 30)}
{--keep-min= : Always keep at least this many newest backups regardless of age (default: workkit.backup.retention_min_keep, falling back to 5; 0 disables the floor)}
{--dry-run : Show what would be deleted without removing anything}';
protected $description = 'Delete backup files older than the retention window, always keeping the N newest.';
protected $description = 'Delete backup files older than the retention window.';
public function handle(): int
{
// Distinguish "not passed" (null) from an explicit --days=0, which must
// error via the < 1 guard rather than being silently swallowed by ?:.
$daysOpt = $this->option('days');
$days = (int) ($daysOpt !== null ? $daysOpt : config('workkit.backup.retention_days', 30));
$days = (int) ($this->option('days') ?: config('workkit.backup.retention_days', 30));
if ($days < 1) {
$this->error('--days must be >= 1');
return self::FAILURE;
}
// Read with an explicit code-side default: a consumer who published an
// OLD config (a `backup` array without this key) would otherwise get
// null here, because mergeConfigFrom does not deep-merge nested keys.
$keepMinOpt = $this->option('keep-min');
$keepMin = (int) ($keepMinOpt !== null ? $keepMinOpt : config('workkit.backup.retention_min_keep', 5));
if ($keepMin < 0) {
$keepMin = 0;
}
if ($keepMin === 0) {
$this->warn('keep-min is 0 — prune may delete every aged backup, leaving zero recovery points.');
}
$base = BackupService::backupDirectory();
$files = glob($base . '/*') ?: [];
$dry = (bool) $this->option('dry-run');
// Newest-first, sidecars excluded. Same ordering the restore resolver
// uses, so the always-kept set and the restore candidate agree.
$files = BackupService::backupFiles();
$cutoff = time() - $days * 86400;
$protected = array_slice($files, 0, $keepMin);
$protectedSet = array_flip($protected);
$removed = 0;
$kept = 0;
foreach ($files as $f) {
if (isset($protectedSet[$f])) {
$kept++;
if (! is_file($f)) {
continue;
}
if (filemtime($f) < $cutoff) {
$meta = BackupService::metaPathFor($f);
if ($dry) {
if ($this->option('dry-run')) {
$this->line("would remove: {$f}");
} else {
@unlink($f);
if (is_file($meta)) {
@unlink($meta);
}
$this->line("removed: {$f}");
}
$removed++;
@ -90,32 +55,12 @@ class PruneBackupsCommand extends Command
}
}
// Sweep orphaned sidecars (backup already gone, e.g. removed by hand).
$base = BackupService::backupDirectory();
$orphans = 0;
foreach (glob($base.'/*'.BackupService::META_EXT) ?: [] as $meta) {
$backup = substr($meta, 0, -strlen(BackupService::META_EXT));
if (! is_file($backup)) {
if ($dry) {
$this->line("would remove orphan sidecar: {$meta}");
} else {
@unlink($meta);
$this->line("removed orphan sidecar: {$meta}");
}
$orphans++;
}
}
$verb = $dry ? 'would-remove' : 'removed';
$this->info(sprintf(
'Backups: %d kept, %d %s, %d orphan sidecar(s) %s (cutoff: %d days, keep-min: %d).',
'Backups: %d kept, %d %s (cutoff: %d days).',
$kept,
$removed,
$verb,
$orphans,
$verb,
$this->option('dry-run') ? 'would-remove' : 'removed',
$days,
$keepMin,
));
return self::SUCCESS;

View File

@ -7,7 +7,6 @@ namespace Blax\Workkit\Commands\Database;
use Blax\Workkit\Services\BackupService;
use Illuminate\Console\Command;
use RuntimeException;
use Throwable;
/**
* Restore a backup produced by workkit:db:backup. Streams the file
@ -15,29 +14,18 @@ use Throwable;
* matching the backup pipeline exactly. PHP allocates nothing for the
* payload, so even multi-GB backups restore without bumping memory_limit.
*
* Without --file, picks the newest *restorable* backup in storage/backups
* by mtime (pre-restore safety snapshots are excluded from auto-pick).
* Refuses to run unless --force is passed or the operator confirms: a
* restore overwrites whatever is currently in the target database.
*
* --fresh drops every table + view in the target first, which is the only
* thing that fixes a restore failing with errno 150 / error 3780 against a
* dirty or partially-migrated schema (incompatible leftover column types).
* Before it drops anything it (a) verifies the source backup decrypts and
* is a valid xz stream and (b) takes + verifies a pre-restore safety
* snapshot, so a failed import never leaves you with an empty database and
* no recovery point the exact trap that loses data.
* Without --file, picks the newest backup in storage/backups by mtime.
* Refuses to run unless --force is passed: a restore overwrites whatever
* is currently in the target database.
*/
class RestoreCommand extends Command
{
protected $signature = 'workkit:db:restore
{--connection= : DB connection to restore into (defaults to config(database.default))}
{--file= : Specific backup filename inside the backups directory (default: newest restorable file by mtime)}
{--fresh : Drop ALL tables and views in the target before importing. Fixes restore-onto-dirty-schema failures (errno 150 / error 3780). Takes a verified pre-restore safety snapshot first.}
{--no-safety-backup : With --fresh, skip the automatic pre-restore safety snapshot. DANGEROUS: no recovery point if the import fails after the wipe.}
{--force : Skip confirmation prompts (non-interactive).}';
{--file= : Specific backup filename inside the backups directory (default: newest by mtime)}
{--force : Skip the confirmation prompt}';
protected $description = 'Restore a streaming, APP_KEY-encrypted database backup. Defaults to the newest restorable file in storage/backups.';
protected $description = 'Restore a streaming, APP_KEY-encrypted database backup. Defaults to the newest file in storage/backups.';
public function handle(): int
{
@ -46,167 +34,54 @@ class RestoreCommand extends Command
if (! $cfg) {
$this->error("Unknown database connection: {$connection}");
return self::FAILURE;
}
if (($cfg['driver'] ?? null) !== 'mysql') {
$this->error("workkit:db:restore currently supports only MySQL connections (got: {$cfg['driver']}).");
return self::FAILURE;
}
$file = $this->resolveFile();
if (! $file) {
$this->error('No backup found.');
return self::FAILURE;
}
if (! file_exists($file)) {
$this->error("Backup file not found: {$file}");
return self::FAILURE;
}
$fresh = (bool) $this->option('fresh');
// 1) Verify the SOURCE before doing anything destructive. Never drop
// tables on the basis of a file that can't even be decrypted.
$v = BackupService::verify($file);
if (! $v['integrity_ok']) {
$this->error('Backup failed verification — refusing to restore.');
$this->line($v['message']);
return self::FAILURE;
}
if (! $v['looks_complete']) {
$this->warn('Note: '.$v['message']);
// A source with no completion marker may be truncated-at-source.
// Importing it is recoverable when a safety snapshot is taken, but
// --fresh --no-safety-backup would wipe the target and repopulate
// it from a partial dump with no way back — refuse that outright.
if ($fresh && $this->option('no-safety-backup')) {
$this->error('Refusing: --fresh --no-safety-backup on a source that looks incomplete (no mysqldump completion marker).');
$this->line('This would wipe the database and import a possibly-truncated dump with no recovery point.');
$this->line('Run workkit:db:verify on the backup, or drop --no-safety-backup so a recovery snapshot is taken first.');
return self::FAILURE;
}
}
if ($v['checksum_matches'] === false) {
$this->warn($v['message']);
}
$this->warn(sprintf(
'About to restore `%s`@%s from: %s (%s)',
'About to restore `%s`@%s from: %s',
$cfg['database'],
$cfg['host'] ?? 'localhost',
$cfg['host'],
$file,
BackupService::humanBytes((int) $v['bytes']),
));
$this->warn('This will OVERWRITE any data that conflicts with the dump.');
// 2) Dirty-target detection — purely informational; never blocks or
// prompts under --force (existing automation restores over data).
$tableCount = $this->safeCountTables($cfg);
if ($tableCount === null) {
$this->line('Target table count: unknown (could not query the database).');
} elseif ($tableCount > 0 && ! $fresh) {
$this->warn(sprintf(
'Target is not empty (%d tables/views). Restoring onto an existing schema can fail with '
.'errno 150 / error 3780 when column types differ — re-run with --fresh if that happens.',
$tableCount,
));
}
if ($fresh) {
$this->warn(sprintf(
'--fresh will DROP ALL %stables/views in `%s`@%s before importing.',
$tableCount === null ? '' : $tableCount.' ',
$cfg['database'],
$cfg['host'] ?? 'localhost',
));
}
// 3) Confirmation. --fresh is a full wipe, so require typed name
// confirmation interactively; --force is the non-interactive gate.
if (! $this->option('force')) {
if ($fresh) {
$typed = (string) $this->ask(sprintf(
"Type the database name '%s' to confirm dropping ALL its tables",
$cfg['database'],
));
if ($typed !== $cfg['database']) {
$this->info('Aborted (name did not match).');
return self::SUCCESS;
}
if (! $v['looks_complete'] && ! $this->confirm('The source dump looks incomplete (no completion marker). Wipe and import it anyway?', false)) {
if (! $this->option('force') && ! $this->confirm('Proceed?', false)) {
$this->info('Aborted.');
return self::SUCCESS;
}
} elseif (! $this->confirm('Proceed?', false)) {
$this->info('Aborted.');
return self::SUCCESS;
}
}
// 4) --fresh: snapshot → verify snapshot → drop. Capture the snapshot
// path BEFORE dropping so every post-drop error can point at it.
$snapshotPath = null;
if ($fresh) {
if (! $this->option('no-safety-backup')) {
try {
$snapshotPath = $this->createSafetySnapshot($connection, $cfg);
} catch (Throwable $e) {
$this->error('Pre-restore safety snapshot failed — aborting before any data is dropped.');
$this->line($e->getMessage());
$this->line('Fix the cause (often disk space or a missing mysqldump), or re-run with '
.'--no-safety-backup to proceed WITHOUT a recovery point (dangerous).');
return self::FAILURE;
}
} else {
$this->warn('Skipping pre-restore safety snapshot (--no-safety-backup). No recovery point if the import fails.');
}
try {
BackupService::dropAllTables($cfg);
$this->line('Dropped all tables/views in the target database.');
} catch (Throwable $e) {
$this->error('Failed to drop existing tables — aborting (no import attempted; your data is intact).');
$this->line($e->getMessage());
return self::FAILURE;
}
}
// 5) Import.
$startedAt = microtime(true);
try {
BackupService::decryptDecompressImport($file, $cfg);
} catch (RuntimeException $e) {
$this->reportImportFailure($e, $connection, $cfg, $file, $fresh, $snapshotPath);
$msg = $e->getMessage();
// openssl's password mismatch error has a recognisable shape;
// translate it into something an operator can act on.
if (str_contains($msg, 'bad decrypt') || str_contains($msg, 'bad magic')) {
$this->error('Decryption failed — likely an APP_KEY mismatch between this host and the host that produced the backup.');
$this->line($msg);
} else {
$this->error($msg);
}
return self::FAILURE;
}
// 6) Post-import sanity: a non-trivial backup that imported nothing is
// a silent-failure signal (swallowed pipe error / empty stream).
if ((int) $v['bytes'] > 1024) {
$after = $this->safeCountTables($cfg);
if ($after === 0) {
$this->warn('Restore reported success but the database has 0 tables — the import may have applied '
.'nothing. Verify the backup (workkit:db:verify) and the APP_KEY.');
}
}
$elapsed = microtime(true) - $startedAt;
$this->info(sprintf('Restore complete in %.1fs.', $elapsed));
if ($snapshotPath) {
$this->line('Pre-restore snapshot kept at: '.$snapshotPath);
}
return self::SUCCESS;
}
@ -214,8 +89,7 @@ class RestoreCommand extends Command
* Resolve the file argument:
* - --file=path/to/X.sql.xz.enc use as-is if it exists
* - --file=basename look up inside storage/backups
* - omitted newest *restorable* file (pre-restore
* safety snapshots excluded)
* - omitted pick newest in storage/backups
*/
private function resolveFile(): ?string
{
@ -226,153 +100,15 @@ class RestoreCommand extends Command
if (is_file($opt)) {
return $opt;
}
$candidate = $base.'/'.ltrim((string) $opt, '/');
$candidate = $base . '/' . ltrim((string) $opt, '/');
return is_file($candidate) ? $candidate : null;
}
$candidates = BackupService::restoreCandidates();
if (! $candidates) {
// Distinguish "directory empty" from "only safety snapshots present"
// so the operator isn't told "No backup found" while a recovery
// snapshot sits right there (it just has to be named explicitly).
if (BackupService::backupFiles()) {
$this->warn('Only pre-restore safety snapshots are present — pass one explicitly with --file=... to restore it.');
}
return null;
}
return $candidates[0];
}
/**
* Dump verify the current database into a distinctly-named pre-restore
* snapshot. Returns its absolute path. Throws if the dump or its
* verification fails, so the caller can abort before dropping anything.
*/
private function createSafetySnapshot(string $connection, array $cfg): string
{
$base = BackupService::backupDirectory();
$stamp = date('Y-m-d_H-i-s');
$path = sprintf(
'%s/db_%s_%s%s%s',
$base,
$connection,
$stamp,
BackupService::PRE_RESTORE_MARK,
BackupService::BACKUP_EXT,
);
if (file_exists($path)) {
throw new RuntimeException("Safety snapshot path already exists, refusing to overwrite: {$path}");
}
// Best-effort disk sanity check — warn, don't block (the estimate is
// rough: ~6× compression is conservative for SQL).
$free = @disk_free_space($base);
$dbBytes = $this->safeEstimateDbBytes($cfg);
if ($free !== false && $dbBytes !== null && $dbBytes > 0) {
$estimate = (int) ($dbBytes / 6);
if ($free < $estimate) {
$this->warn(sprintf(
'Low disk on backup volume: %s free, snapshot may need ~%s. Proceeding anyway.',
BackupService::humanBytes((int) $free),
BackupService::humanBytes($estimate),
));
}
}
$this->line('Creating pre-restore safety snapshot…');
BackupService::dumpCompressEncrypt($cfg, $path, (int) config('workkit.backup.xz_level', 3));
$sv = BackupService::verify($path);
if (! $sv['integrity_ok']) {
// Don't leave an unverifiable snapshot (and its sidecar) behind —
// it would clutter the dir and collide with a same-second retry.
@unlink($path);
@unlink(BackupService::metaPathFor($path));
throw new RuntimeException('Safety snapshot did not verify: '.$sv['message']);
}
$this->info(sprintf(
'Safety snapshot saved + verified: %s (%s)',
$path,
BackupService::humanBytes((int) $sv['bytes']),
));
return $path;
}
/**
* Translate an import failure into operator-actionable guidance, and
* when --fresh already dropped the schema make the recovery path
* impossible to miss.
*/
private function reportImportFailure(
RuntimeException $e,
string $connection,
array $cfg,
string $file,
bool $fresh,
?string $snapshotPath
): void {
$msg = $e->getMessage();
if (str_contains($msg, 'bad decrypt') || str_contains($msg, 'bad magic')) {
$this->error('Decryption failed — likely an APP_KEY mismatch between this host and the host that produced the backup.');
$this->line($msg);
} elseif (preg_match('/\b3780\b|\b1215\b|errno:?\s*150|incompatible/i', $msg)) {
// Match the error code, not the broad phrase "foreign key", to
// avoid false-positives on unrelated FK row failures.
$this->error('Import failed on a foreign-key / column-type incompatibility (errno 150 / error 3780).');
if (! $fresh) {
$this->line('The target has a conflicting/leftover schema. Re-run with --fresh to drop existing tables first:');
$this->line(sprintf(
' php artisan workkit:db:restore --connection=%s --file=%s --fresh',
$connection,
basename($file),
));
$this->line('A non-fresh import may have PARTIALLY applied before failing, so the database is now in a mixed state.');
} else {
$this->line($msg);
}
} else {
$this->error($msg);
}
if ($fresh) {
$this->newLine();
if ($snapshotPath) {
$this->error('THE TARGET DATABASE IS NOW EMPTY — --fresh dropped it before the failed import.');
$this->warn('Recover the pre-restore state with:');
$this->line(sprintf(
' php artisan workkit:db:restore --connection=%s --file=%s',
$connection,
$snapshotPath,
));
} else {
$this->error('THE TARGET DATABASE IS NOW EMPTY and no safety snapshot was taken (--no-safety-backup).');
$this->warn('Restore from your most recent good backup: workkit:db:restore --connection='.$connection.' --file=…');
}
}
}
private function safeCountTables(array $cfg): ?int
{
try {
return BackupService::countTables($cfg);
} catch (Throwable $e) {
return null;
}
}
private function safeEstimateDbBytes(array $cfg): ?int
{
try {
return BackupService::estimateDatabaseBytes($cfg);
} catch (Throwable $e) {
$files = glob($base . '/*');
if (! $files) {
return null;
}
usort($files, fn($a, $b) => filemtime($b) <=> filemtime($a));
return $files[0];
}
}

View File

@ -1,173 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Workkit\Commands\Database;
use Blax\Workkit\Services\BackupService;
use Blax\Workkit\Services\QueueHealthService;
use Blax\Workkit\Services\StatsService;
use Illuminate\Console\Command;
use Throwable;
/**
* An at-a-glance "admin overview" in the terminal: row count per Eloquent
* model, the largest tables by size, the total database footprint, and a
* compact queue + backup line. Read-only.
*
* Counts are estimated (information_schema, instant) by default on MySQL; pass
* --exact for a true COUNT(*) per table (heavier on big tables).
*/
class StatsCommand extends Command
{
protected $signature = 'workkit:stats
{--connection= : DB connection (defaults to config(database.default))}
{--exact : Exact COUNT(*) per table instead of the fast estimate}
{--limit=15 : How many of the largest tables to list}
{--models : Only show per-model counts}
{--tables : Only show per-table sizes}
{--json : Output machine-readable JSON}';
protected $description = 'Show a database overview: per-model row counts, largest tables, total size, queue + backup summary.';
public function handle(): int
{
$connection = $this->option('connection') ?: config('database.default');
if (! config("database.connections.{$connection}")) {
$this->error("Unknown database connection: {$connection}");
return self::FAILURE;
}
$exact = (bool) $this->option('exact');
$limit = max(1, (int) $this->option('limit'));
$onlyModels = (bool) $this->option('models');
$onlyTables = (bool) $this->option('tables');
$showAll = ! $onlyModels && ! $onlyTables;
$data = ['connection' => $connection];
if ($onlyModels || $showAll) {
$data['models'] = StatsService::modelCounts(null, $exact, $connection);
}
if ($onlyTables || $showAll) {
$data['tables'] = array_slice(StatsService::tableStats($connection), 0, $limit);
$data['database'] = StatsService::databaseSize($connection);
}
if ($showAll) {
$data['queue'] = $this->safe(fn () => $this->queueSummary());
$data['backup'] = $this->safe(fn () => $this->backupSummary());
}
if ($this->option('json')) {
$this->line((string) json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
$this->render($data, $exact);
return self::SUCCESS;
}
private function render(array $data, bool $exact): void
{
if (isset($data['models'])) {
$this->info('Models'.($exact ? ' (exact)' : ' (estimated)'));
$rows = [];
foreach ($data['models'] as $m) {
$rows[] = [
$m['model'],
$m['table'],
($m['error'] !== null || $m['count'] === null) ? '—' : number_format((int) $m['count']).($m['estimated'] ? ' ~' : ''),
];
}
$rows === []
? $this->line(' (no models discovered — set config workkit.stats.models)')
: $this->table(['Model', 'Table', 'Rows'], $rows);
}
if (isset($data['tables'])) {
$this->info('Largest tables');
$rows = [];
foreach ($data['tables'] as $t) {
$rows[] = [
$t['table'],
$t['rows'] === null ? '—' : number_format((int) $t['rows']),
$t['bytes'] === null ? '—' : BackupService::humanBytes((int) $t['bytes']),
$t['engine'] ?? '—',
];
}
$rows === []
? $this->line(' (no tables found)')
: $this->table(['Table', 'Rows', 'Size', 'Engine'], $rows);
if (isset($data['database'])) {
$db = $data['database'];
$this->line(sprintf(
'Database: %d tables, %s total',
$db['tables'],
$db['bytes'] === null ? 'size n/a (non-MySQL)' : BackupService::humanBytes((int) $db['bytes']),
));
}
}
if (! empty($data['queue'])) {
$q = $data['queue'];
$this->line(sprintf('Queue: %s pending, %s failed (24h)', $q['pending'] ?? '?', $q['failed_recent'] ?? '?'));
}
if (! empty($data['backup'])) {
$b = $data['backup'];
$this->line(sprintf('Backups: %d on disk, newest %s', $b['count'] ?? 0, $b['newest_age'] ?? 'none'));
}
}
private function queueSummary(): array
{
$snap = QueueHealthService::inspect();
$pending = 0;
foreach ($snap['queues'] as $q) {
$pending += (int) $q['pending'];
}
return ['pending' => $pending, 'failed_recent' => $snap['failed_recent']];
}
private function backupSummary(): array
{
$files = BackupService::backupFiles();
$newest = $files[0] ?? null;
$age = null;
if ($newest !== null) {
$secs = max(0, time() - (int) filemtime($newest));
$age = $this->humanAge($secs);
}
return ['count' => count($files), 'newest_age' => $age];
}
private function humanAge(int $secs): string
{
if ($secs < 90) {
return $secs.'s ago';
}
if ($secs < 5400) {
return intdiv($secs, 60).'m ago';
}
if ($secs < 172800) {
return intdiv($secs, 3600).'h ago';
}
return intdiv($secs, 86400).'d ago';
}
/** Run a best-effort summary probe; return null if it throws. */
private function safe(callable $fn): ?array
{
try {
return $fn();
} catch (Throwable $e) {
return null;
}
}
}

View File

@ -1,120 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Workkit\Commands\Database;
use Blax\Workkit\Services\BackupService;
use Illuminate\Console\Command;
/**
* Check that a backup is intact WITHOUT touching the database. Streams
* openssl decrypt xz integrity-test tail, so it proves the file
* decrypts with this host's APP_KEY and is a complete, non-truncated xz
* stream, and (for mysqldump output) that it ends with the
* "-- Dump completed" marker. When a .meta.json sidecar exists it also
* confirms the on-disk sha256 still matches what was recorded at backup
* time.
*
* This is exactly the check that distinguishes "the backup is corrupt"
* from "the import failed for schema reasons" the misdiagnosis that
* otherwise gets a perfectly good backup deleted.
*
* Caveat: integrity restorability. A valid backup of the wrong (or
* empty) database verifies "OK"; verify proves the bytes are sound, not
* that the SQL will apply cleanly against a given schema.
*/
class VerifyCommand extends Command
{
protected $signature = 'workkit:db:verify
{--file= : Specific backup filename inside the backups directory (default: newest)}
{--all : Verify every backup in the backups directory}';
protected $description = 'Verify a backup decrypts + is a valid (complete) xz stream, without importing it.';
public function handle(): int
{
$targets = $this->resolveTargets();
if ($targets === null) {
return self::FAILURE;
}
if ($targets === []) {
$this->warn('No backups found to verify.');
return self::SUCCESS;
}
$ok = 0;
$bad = 0;
$incomplete = 0;
foreach ($targets as $file) {
$r = BackupService::verify($file);
$label = basename($file).' ('.BackupService::humanBytes((int) $r['bytes']).')';
if (! $r['integrity_ok']) {
$bad++;
$this->error("{$label}");
$this->line(' '.$r['message']);
continue;
}
if ($r['checksum_matches'] === false) {
$bad++;
$this->error("{$label}");
$this->line(' '.$r['message']);
continue;
}
if (! $r['looks_complete']) {
$incomplete++;
$this->warn("{$label}");
$this->line(' '.$r['message']);
continue;
}
$ok++;
$suffix = $r['checksum_matches'] === true ? ' (checksum matches sidecar)' : '';
$this->info("{$label}{$suffix}");
}
$this->newLine();
$this->line(sprintf('Verified: %d OK, %d incomplete/uncertain, %d failed.', $ok, $incomplete, $bad));
return $bad > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* @return string[]|null list of files to verify, or null on a hard error
*/
private function resolveTargets(): ?array
{
if ($this->option('all')) {
return BackupService::backupFiles();
}
$base = BackupService::backupDirectory();
$opt = $this->option('file');
if ($opt) {
if (is_file($opt)) {
return [$opt];
}
$candidate = $base.'/'.ltrim((string) $opt, '/');
if (! is_file($candidate)) {
$this->error("Backup file not found: {$opt}");
return null;
}
return [$candidate];
}
$files = BackupService::backupFiles();
return $files === [] ? [] : [$files[0]];
}
}

View File

@ -1,103 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Workkit\Commands\Queue;
use Blax\Workkit\Services\QueueHealthService;
use Illuminate\Console\Command;
/**
* Report queue health and exit non-zero when a threshold is breached, so it
* drops straight into cron / a monitoring probe:
*
* * * * * * php artisan workkit:queue:health --json || alert
*
* For the database queue driver it shows per-queue depth, due/delayed/reserved
* counts and the oldest due job's age, and flags a STALLED queue (due work past
* the age limit with nothing reserved the worker is probably down). Failed
* jobs (total + last 24h) are reported on any driver.
*/
class QueueHealthCommand extends Command
{
protected $signature = 'workkit:queue:health
{--connection= : Queue connection (defaults to config(queue.default))}
{--max-age= : Stall threshold in minutes (default: workkit.queue.max_age_minutes)}
{--max-depth= : Per-queue backlog threshold (default: workkit.queue.max_depth)}
{--max-failed= : Max failed jobs in the window (default: workkit.queue.max_failed)}
{--window=24 : Failed-jobs lookback window in hours}
{--json : Output machine-readable JSON}';
protected $description = 'Report queue depth/age/failed-jobs health; exit non-zero on any threshold breach.';
public function handle(): int
{
$connection = $this->option('connection') ?: config('queue.default');
$window = max(1, (int) $this->option('window'));
$snapshot = QueueHealthService::inspect($connection, $window);
$thresholds = QueueHealthService::thresholds();
foreach (['max-age' => 'max_age_minutes', 'max-depth' => 'max_depth', 'max-failed' => 'max_failed'] as $opt => $key) {
$val = $this->option($opt);
if ($val !== null) {
$thresholds[$key] = (int) $val;
}
}
$result = QueueHealthService::evaluate($snapshot, $thresholds);
if ($this->option('json')) {
$this->line((string) json_encode(
['snapshot' => $snapshot, 'thresholds' => $thresholds] + $result,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
));
return $result['ok'] ? self::SUCCESS : self::FAILURE;
}
$this->line(sprintf('Queue connection: %s (driver: %s)', $snapshot['connection'], $snapshot['driver'] ?? 'unknown'));
if ($snapshot['supported']) {
if ($snapshot['queues'] === []) {
$this->line(' (no jobs queued)');
} else {
$rows = [];
foreach ($snapshot['queues'] as $name => $q) {
$rows[] = [
$name,
number_format($q['pending']),
number_format($q['due']),
number_format($q['delayed']),
number_format($q['reserved']),
$q['oldest_due_age'] === null ? '—' : intdiv($q['oldest_due_age'], 60).'m',
];
}
$this->table(['Queue', 'Pending', 'Due', 'Delayed', 'Reserved', 'Oldest due'], $rows);
}
} else {
$this->warn(sprintf('Per-queue introspection unsupported for the "%s" driver — showing failed jobs only.', $snapshot['driver'] ?? 'unknown'));
}
$this->line(sprintf(
'Failed jobs: %s total, %s in last %dh',
$snapshot['failed_total'] ?? 'n/a',
$snapshot['failed_recent'] ?? 'n/a',
$snapshot['failed_window_hours'],
));
if ($result['ok']) {
$this->info('Queue health: OK');
return self::SUCCESS;
}
$this->newLine();
$this->error('Queue health: '.count($result['breaches']).' issue(s)');
foreach ($result['breaches'] as $b) {
$this->line(' • '.$b['message']);
}
return self::FAILURE;
}
}

View File

@ -1,22 +0,0 @@
<?php
namespace Blax\Workkit\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ForceJsonResponse
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
$request->headers->set('Accept', 'application/json');
return $next($request);
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace Blax\Workkit\Middleware;
use Blax\Workkit\Services\ResponseService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RequireAuthMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string $action = 'continue'): Response
{
if (! Auth::check()) {
return ResponseService::apiError(
"You need to be logged in to {$action}.",
Response::HTTP_UNAUTHORIZED,
type: 'AuthenticationException',
);
}
return $next($request);
}
}

View File

@ -28,31 +28,12 @@ use RuntimeException;
* Required system binaries: `mysqldump`, `mysql`, `xz`, `openssl`,
* `bash` (for `set -o pipefail`). All are standard on every reasonable
* Linux server.
*
* IMPORTANT single source of truth for the target server: every path
* that touches MySQL (dump, import, drop, table/size queries) builds its
* client invocation from the SAME `$cfg` array via {@see mysqlClient()}.
* Never mix in Laravel's PDO connection for one operation and the raw
* `$cfg` CLI for another a `url` DSN, `unix_socket`, or read/write split
* can make those two resolve to *different databases*, and a restore that
* drops one schema while importing into another is exactly the
* catastrophe these commands exist to prevent.
*/
class BackupService
{
public const CIPHER = 'aes-256-cbc';
public const PBKDF2_ITER = 600000;
/** Canonical extension every backup this package writes carries. */
public const BACKUP_EXT = '.sql.xz.enc';
/** Suffix marking a pre-restore safety snapshot (excluded from auto-restore). */
public const PRE_RESTORE_MARK = '.pre-restore';
/** Sidecar metadata suffix written next to each backup (best-effort). */
public const META_EXT = '.meta.json';
/**
* mysqldump xz openssl enc $outPath. One pipeline, zero PHP
* memory pressure. Pipefail propagates a failure in any stage out
@ -71,21 +52,21 @@ class BackupService
$level = max(0, min(9, $xzLevel));
$mysqldump = 'mysqldump '
.'--single-transaction --quick --skip-lock-tables '
.'--user='.escapeshellarg((string) ($cfg['username'] ?? 'root')).' '
.'--host='.escapeshellarg((string) ($cfg['host'] ?? 'localhost')).' '
.'--port='.escapeshellarg((string) ($cfg['port'] ?? 3306)).' '
.escapeshellarg((string) $cfg['database']);
. '--single-transaction --quick --skip-lock-tables '
. '--user=' . escapeshellarg((string) ($cfg['username'] ?? 'root')) . ' '
. '--host=' . escapeshellarg((string) ($cfg['host'] ?? 'localhost')) . ' '
. '--port=' . escapeshellarg((string) ($cfg['port'] ?? 3306)) . ' '
. escapeshellarg((string) $cfg['database']);
$xz = "xz -{$level} -T0";
$openssl = 'openssl enc -'.self::CIPHER.' -pbkdf2 -iter '.self::PBKDF2_ITER.' -salt -pass env:WK_KEY';
$openssl = 'openssl enc -' . self::CIPHER . ' -pbkdf2 -iter ' . self::PBKDF2_ITER . ' -salt -pass env:WK_KEY';
// bash -c with pipefail: any stage failing trips the whole
// pipeline. Without pipefail, a mysqldump crash with xz still
// running would leave the output file 0-byte and the exit
// code 0 — silent corruption.
$pipeline = "{$mysqldump} | {$xz} | {$openssl} > ".escapeshellarg($outPath);
$cmd = '/bin/bash -c '.escapeshellarg('set -o pipefail; '.$pipeline);
$pipeline = "{$mysqldump} | {$xz} | {$openssl} > " . escapeshellarg($outPath);
$cmd = '/bin/bash -c ' . escapeshellarg('set -o pipefail; ' . $pipeline);
try {
self::run($cmd, [
@ -105,30 +86,12 @@ class BackupService
if (! file_exists($outPath) || filesize($outPath) === 0) {
throw new RuntimeException("Backup pipeline produced no output at {$outPath}");
}
// Best-effort metadata sidecar — never fail a good backup over it.
self::writeMeta($outPath, $cfg, $level);
}
/**
* openssl dec xz -d mysql. Streams through the same pipeline
* in reverse. Does no PHP-side decoding so the file size is bounded
* only by disk I/O.
*
* A `SET FOREIGN_KEY_CHECKS=0;` line is prepended to the SQL stream so
* that even a hand-rolled dump lacking mysqldump's own header imports
* regardless of table order (forward FK references resolve once every
* table exists). This is harmless for mysqldump output, which sets and
* restores its own session vars, and the whole import is a single
* short-lived mysql session so the relaxed checks never leak out.
*
* NOTE: this does NOT rescue a restore onto a *dirty* schema whose
* leftover tables have incompatible column types MySQL still raises
* errno 150 / error 3780 at CREATE TABLE because the referenced table
* already exists with the wrong type. For that case the target must be
* empty first (see RestoreCommand --fresh). The openssl|xz stages stay
* a real pipeline segment so `set -o pipefail` still surfaces a bad key
* or corrupt file as a non-zero exit.
*/
public static function decryptDecompressImport(string $inPath, array $cfg): void
{
@ -137,17 +100,31 @@ class BackupService
self::requireBinary('openssl');
self::requireBinary('bash');
self::assertLooksEncrypted($inPath);
// Sanity check on the file's magic bytes. `openssl enc -salt`
// output always starts with the literal "Salted__" header.
$head = (string) @file_get_contents($inPath, false, null, 0, 8);
if ($head !== 'Salted__') {
throw new RuntimeException(
"File at {$inPath} doesn't look like a streaming openssl backup. "
. 'Legacy backups (made with the pre-streaming Crypt envelope) need '
. 'a separate restore path; see workkit:db:restore-legacy or restore '
. 'manually with `Crypt::decryptString` after raising memory_limit.'
);
}
$openssl = self::opensslDecrypt($inPath);
$mysql = self::mysqlClient($cfg);
$openssl = 'openssl enc -d -' . self::CIPHER
. ' -pbkdf2 -iter ' . self::PBKDF2_ITER
. ' -pass env:WK_KEY '
. '-in ' . escapeshellarg($inPath);
// The brace group's exit status is that of its last command (the
// openssl|xz pipeline), so under `set -o pipefail` a decrypt/decompress
// failure still propagates out as the head of the outer pipeline.
$payload = "{ printf 'SET FOREIGN_KEY_CHECKS=0;\\n'; {$openssl} | xz -d -T0; }";
$pipeline = "{$payload} | {$mysql}";
$cmd = '/bin/bash -c '.escapeshellarg('set -o pipefail; '.$pipeline);
$mysql = 'mysql '
. '--user=' . escapeshellarg((string) ($cfg['username'] ?? 'root')) . ' '
. '--host=' . escapeshellarg((string) ($cfg['host'] ?? 'localhost')) . ' '
. '--port=' . escapeshellarg((string) ($cfg['port'] ?? 3306)) . ' '
. escapeshellarg((string) $cfg['database']);
$pipeline = "{$openssl} | xz -d -T0 | {$mysql}";
$cmd = '/bin/bash -c ' . escapeshellarg('set -o pipefail; ' . $pipeline);
self::run($cmd, [
'MYSQL_PWD' => (string) ($cfg['password'] ?? ''),
@ -155,217 +132,6 @@ class BackupService
]);
}
/**
* Drop every base table and view in the target database, then leave
* the (empty) database in place. Built entirely from $cfg via the same
* mysql client the import uses, and scoped to `DATABASE()` (the schema
* the CLI connects to) so the wipe provably hits the same server +
* schema the subsequent import writes to.
*
* Implemented as in-SQL prepared statements rather than DROP DATABASE
* so it never needs CREATE privilege and preserves the database's
* charset/collation and any grants attached to it.
*/
public static function dropAllTables(array $cfg): void
{
self::requireBinary('mysql');
self::requireBinary('bash');
// Tables and views dropped in two single statements (GROUP_CONCAT
// builds `a`,`b`,`c`); FK checks off so table order is irrelevant.
$sql = <<<'SQL'
SET FOREIGN_KEY_CHECKS = 0;
SET SESSION group_concat_max_len = 1000000000;
SET @t := (SELECT GROUP_CONCAT(CONCAT('`', table_name, '`')) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE');
SET @v := (SELECT GROUP_CONCAT(CONCAT('`', table_name, '`')) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_type = 'VIEW');
SET @dt := IF(@t IS NULL, 'SELECT 1', CONCAT('DROP TABLE IF EXISTS ', @t));
PREPARE s1 FROM @dt; EXECUTE s1; DEALLOCATE PREPARE s1;
SET @dv := IF(@v IS NULL, 'SELECT 1', CONCAT('DROP VIEW IF EXISTS ', @v));
PREPARE s2 FROM @dv; EXECUTE s2; DEALLOCATE PREPARE s2;
SET FOREIGN_KEY_CHECKS = 1;
SQL;
$pipeline = 'printf %s '.escapeshellarg($sql).' | '.self::mysqlClient($cfg);
$cmd = '/bin/bash -c '.escapeshellarg('set -o pipefail; '.$pipeline);
self::run($cmd, ['MYSQL_PWD' => (string) ($cfg['password'] ?? '')]);
}
/**
* Verify a backup without touching MySQL: decrypt + fully decompress and
* inspect the tail. Returns a structured result rather than throwing so
* callers (workkit:db:verify, the --fresh pre-drop gate) can branch on it.
*
* What it proves:
* - integrity_ok : decrypts with the current APP_KEY AND is a valid,
* non-truncated xz stream (xz -d fails otherwise).
* - looks_complete : the plaintext ends with mysqldump's
* "-- Dump completed" marker catches a dump that
* was truncated AT SOURCE (mysqldump crashed mid-run)
* yet compressed cleanly, which xz integrity can't.
* - checksum_matches: on-disk sha256 equals the value recorded in the
* .meta.json sidecar (proves the file wasn't altered
* on disk since it was written), or null if no sidecar.
*
* It does NOT prove the dump is restorable against a given schema a
* valid backup of the wrong/empty database still verifies "OK".
*
* @return array{file:string,bytes:int,integrity_ok:bool,looks_complete:bool,sha256:?string,recorded_sha256:?string,checksum_matches:?bool,message:string}
*/
public static function verify(string $path): array
{
self::requireBinary('openssl');
self::requireBinary('xz');
self::requireBinary('bash');
$result = [
'file' => $path,
'bytes' => is_file($path) ? (int) filesize($path) : 0,
'integrity_ok' => false,
'looks_complete' => false,
'sha256' => null,
'recorded_sha256' => null,
'checksum_matches' => null,
'message' => '',
];
if (! is_file($path)) {
$result['message'] = "Backup file not found: {$path}";
return $result;
}
$head = (string) @file_get_contents($path, false, null, 0, 8);
if ($head !== 'Salted__') {
$result['message'] = "Not a streaming openssl backup (missing 'Salted__' header). "
.'Legacy Crypt-envelope backups need a different restore path.';
return $result;
}
// Decrypt + decompress in one streaming pass, keeping only the tail
// for the completion-marker check. xz -d fails on truncation/bit-rot.
$pipeline = self::opensslDecrypt($path).' | xz -d -T0 | tail -c 512';
$cmd = '/bin/bash -c '.escapeshellarg('set -o pipefail; '.$pipeline);
[$exit, $stdout, $stderr] = self::runResult($cmd, ['WK_KEY' => self::passphrase()]);
if ($exit !== 0) {
if (str_contains($stderr, 'bad decrypt') || str_contains($stderr, 'bad magic')) {
$result['message'] = 'Decryption failed — APP_KEY likely does not match the host that produced this backup.';
} else {
// openssl can (rarely, ~1/256) yield valid PKCS#7 padding on a
// wrong key and exit 0, leaving xz to choke on garbage — so a
// post-decrypt xz failure is ambiguous between the two causes.
$result['message'] = 'Decrypted but the stream is not valid xz — the backup is corrupt OR '
.'(rarely) the APP_KEY is wrong and the padding happened to validate. Verify APP_KEY first. '
.'stderr: '.trim($stderr);
}
return $result;
}
$result['integrity_ok'] = true;
$result['looks_complete'] = str_contains($stdout, '-- Dump completed');
// sha256 + sidecar comparison (best-effort; streams the file).
$sha = @hash_file('sha256', $path);
$result['sha256'] = $sha === false ? null : $sha;
$meta = self::readMeta($path);
if ($meta !== null && isset($meta['sha256']) && is_string($meta['sha256'])) {
$result['recorded_sha256'] = $meta['sha256'];
if ($result['sha256'] !== null) {
$result['checksum_matches'] = hash_equals($meta['sha256'], $result['sha256']);
}
}
if ($result['checksum_matches'] === false) {
$result['message'] = 'CHECKSUM MISMATCH — the file changed on disk since it was written (integrity of the xz stream is still OK).';
} elseif (! $result['looks_complete']) {
$result['message'] = 'Integrity OK (decrypts + valid xz) but no mysqldump completion marker — '
.'may be truncated at source or a non-mysqldump dump.';
} else {
$result['message'] = 'OK — decrypts, valid xz, mysqldump completion marker present'
.($result['checksum_matches'] === true ? ', checksum matches sidecar.' : '.');
}
return $result;
}
/**
* Count base tables + views in the target database, via the same mysql
* client the import/drop use. Returns null if the query can't run (no
* connection, database missing) so callers can treat it as "unknown".
*/
public static function countTables(array $cfg): ?int
{
$val = self::scalarQuery(
$cfg,
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE()'
);
return $val === null ? null : (int) $val;
}
/**
* Approximate on-disk size (bytes) of the target database from
* information_schema. Null if unavailable. Used only for a best-effort
* pre-snapshot disk-space sanity check.
*/
public static function estimateDatabaseBytes(array $cfg): ?int
{
$val = self::scalarQuery(
$cfg,
'SELECT IFNULL(SUM(data_length + index_length), 0) FROM information_schema.tables WHERE table_schema = DATABASE()'
);
return $val === null ? null : (int) $val;
}
/**
* Real backup files in the backup directory, newest first. Excludes
* `.meta.json` sidecars but KEEPS custom `--out`-named backups (we only
* deny the sidecar suffix rather than allow-listing one extension, so a
* consumer's nightly.enc / db.bak is still found and pruned).
*
* @return string[] absolute paths, newest (by mtime) first
*/
public static function backupFiles(): array
{
$base = self::backupDirectory();
$files = glob($base.'/*') ?: [];
$files = array_values(array_filter($files, function ($f) {
return is_file($f) && ! str_ends_with($f, self::META_EXT);
}));
usort($files, fn ($a, $b) => filemtime($b) <=> filemtime($a));
return $files;
}
/**
* Like {@see backupFiles()} but also drops pre-restore safety snapshots,
* so the "newest backup" auto-resolver never silently picks a snapshot of
* a known-bad database taken moments before a wipe.
*
* @return string[]
*/
public static function restoreCandidates(): array
{
return array_values(array_filter(
self::backupFiles(),
fn ($f) => ! str_contains(basename($f), self::PRE_RESTORE_MARK.'.')
));
}
/**
* Derive the metadata sidecar path for a backup file. Always
* backup sidecar (never the reverse), so a backup with no sidecar is
* still handled and a lone sidecar is recognisably an orphan.
*/
public static function metaPathFor(string $backupPath): string
{
return $backupPath.self::META_EXT;
}
/**
* Derive the openssl passphrase from APP_KEY. Laravel stores APP_KEY
* as "base64:<random-bytes>"; we strip the prefix and feed the rest
@ -381,7 +147,6 @@ SQL;
if (str_starts_with($key, 'base64:')) {
$key = substr($key, 7);
}
return $key;
}
@ -425,8 +190,8 @@ SQL;
}
throw new RuntimeException(sprintf(
"Backup directory not writable: %s\n"
." owner=%s, current user=%s\n"
.' Fix as root: chown -R %s %s && chmod 0775 %s',
. " owner=%s, current user=%s\n"
. " Fix as root: chown -R %s %s && chmod 0775 %s",
$path,
$owner,
$current,
@ -446,7 +211,7 @@ SQL;
*/
public static function requireBinary(string $bin): void
{
$found = trim((string) @shell_exec('command -v '.escapeshellarg($bin)));
$found = trim((string) @shell_exec('command -v ' . escapeshellarg($bin)));
if ($found === '') {
throw new RuntimeException("Required binary `{$bin}` not found on PATH.");
}
@ -454,34 +219,11 @@ SQL;
/**
* Run a shell command and throw on non-zero exit, capturing stderr
* for the error message.
* for the error message. Env vars are passed via proc_open's env
* arg they're scoped to the child process and not visible in the
* host's `ps` listing.
*/
public static function run(string $command, array $env = []): void
{
[$exit, , $stderr] = self::runResult($command, $env);
if ($exit !== 0) {
throw new RuntimeException(sprintf(
"Command failed (exit %d):\n %s\nstderr:\n%s",
$exit,
self::redactCommand($command),
trim((string) $stderr) ?: '(empty)'
));
}
}
/**
* Run a shell command and return [exitCode, stdout, stderr] without
* throwing. Env vars are passed via proc_open's env arg scoped to the
* child process, never visible in the host's `ps` listing.
*
* stdout is read fully; in this package the only stdout producers are
* tiny (a `tail -c 512`, a scalar query, a row of SHOW output), so this
* stays well within the zero-DB-bytes-in-PHP invariant. stdout and stderr
* are drained concurrently (non-blocking + stream_select) so a child that
* fills both pipe buffers at once can never deadlock against a serial read.
*/
public static function runResult(string $command, array $env = []): array
{
$descriptors = [
0 => ['pipe', 'r'],
@ -496,161 +238,19 @@ SQL;
}
fclose($pipes[0]);
stream_set_blocking($pipes[1], false);
stream_set_blocking($pipes[2], false);
$stdout = '';
$stderr = '';
$open = [1 => $pipes[1], 2 => $pipes[2]];
while ($open !== []) {
$read = array_values($open);
$write = null;
$except = null;
if (@stream_select($read, $write, $except, null) === false) {
break;
}
foreach ($read as $stream) {
$chunk = fread($stream, 8192);
if (($chunk === '' || $chunk === false) && feof($stream)) {
$key = array_search($stream, $open, true);
if ($key !== false) {
fclose($stream);
unset($open[$key]);
}
continue;
}
if ($chunk === false) {
continue;
}
if ($stream === $pipes[1]) {
$stdout .= $chunk;
} else {
$stderr .= $chunk;
}
}
}
// Close anything stream_select bailed on, then reap the process.
foreach ($open as $stream) {
fclose($stream);
}
fclose($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[2]);
$exit = proc_close($proc);
return [$exit, $stdout, $stderr];
}
/** Human-readable byte size, e.g. 13631488 → "13.00 MB". */
public static function humanBytes(int $bytes): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
$value = (float) $bytes;
while ($value >= 1024 && $i < count($units) - 1) {
$value /= 1024;
$i++;
}
return sprintf('%.2f %s', $value, $units[$i]);
}
// ---- internals ---------------------------------------------------------
/**
* Build the mysql client invocation from $cfg. Single source of truth
* for the target server across import, drop and queries.
*/
private static function mysqlClient(array $cfg): string
{
return 'mysql '
.'--user='.escapeshellarg((string) ($cfg['username'] ?? 'root')).' '
.'--host='.escapeshellarg((string) ($cfg['host'] ?? 'localhost')).' '
.'--port='.escapeshellarg((string) ($cfg['port'] ?? 3306)).' '
.escapeshellarg((string) $cfg['database']);
}
/** The openssl decrypt stage, reading from $inPath. */
private static function opensslDecrypt(string $inPath): string
{
return 'openssl enc -d -'.self::CIPHER
.' -pbkdf2 -iter '.self::PBKDF2_ITER
.' -pass env:WK_KEY '
.'-in '.escapeshellarg($inPath);
}
/** Sanity-check the openssl `-salt` magic header. */
private static function assertLooksEncrypted(string $inPath): void
{
$head = (string) @file_get_contents($inPath, false, null, 0, 8);
if ($head !== 'Salted__') {
throw new RuntimeException(
"File at {$inPath} doesn't look like a streaming openssl backup. "
.'Legacy backups (made with the pre-streaming Crypt envelope) need '
.'a separate restore path; see workkit:db:restore-legacy or restore '
.'manually with `Crypt::decryptString` after raising memory_limit.'
);
}
}
/**
* Run a single-row, single-column query against $cfg and return the
* trimmed scalar, or null if the query fails (no connection / missing db).
*/
private static function scalarQuery(array $cfg, string $sql): ?string
{
self::requireBinary('mysql');
self::requireBinary('bash');
$pipeline = self::mysqlClient($cfg).' -N -e '.escapeshellarg($sql);
$cmd = '/bin/bash -c '.escapeshellarg('set -o pipefail; '.$pipeline);
[$exit, $stdout] = self::runResult($cmd, ['MYSQL_PWD' => (string) ($cfg['password'] ?? '')]);
if ($exit !== 0) {
return null;
throw new RuntimeException(sprintf(
"Command failed (exit %d):\n %s\nstderr:\n%s",
$exit,
self::redactCommand($command),
trim((string) $stderr) ?: '(empty)'
));
}
return trim($stdout);
}
/**
* Write a best-effort metadata sidecar next to a freshly-created backup.
* Never throws: a backup is valid with or without its sidecar.
*/
private static function writeMeta(string $backupPath, array $cfg, int $xzLevel): void
{
try {
$sha = @hash_file('sha256', $backupPath);
$meta = [
'file' => basename($backupPath),
'database' => $cfg['database'] ?? null,
'host' => $cfg['host'] ?? null,
'created_at' => date('c'),
'bytes' => (int) @filesize($backupPath),
'sha256' => $sha === false ? null : $sha,
'cipher' => self::CIPHER,
'pbkdf2_iter' => self::PBKDF2_ITER,
'xz_level' => $xzLevel,
];
@file_put_contents(
self::metaPathFor($backupPath),
json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
} catch (\Throwable $e) {
// Sidecar is a nicety, not a requirement.
}
}
/** Read a backup's metadata sidecar, or null if absent/unreadable. */
private static function readMeta(string $backupPath): ?array
{
$metaPath = self::metaPathFor($backupPath);
if (! is_file($metaPath)) {
return null;
}
$decoded = json_decode((string) @file_get_contents($metaPath), true);
return is_array($decoded) ? $decoded : null;
}
/**

View File

@ -5,91 +5,32 @@ namespace Blax\Workkit\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Grab-bag of small utilities used across Blax host apps.
*
* Response-envelope helpers live on {@see ResponseService} now and the old
* shims here have been retired alongside the legacy methods
* (`response`, `apiResponse`, `asPaginated`, `paginationMeta`) that were
* dropped from ResponseService itself. The remaining `apiItem`,
* `apiCollection`, `apiPaginated`, `apiMeta`, `availableLanguages` shims
* stay for the moment as a courtesy to existing callers new code should
* call {@see ResponseService} directly.
*/
class MiscService
{
/* ──────────────────────────────────────────────────────────────────────
* Response envelope (delegates to ResponseService)
* ────────────────────────────────────────────────────────────────────── */
/**
* Available content languages for the running app.
* See {@see ResponseService::availableLanguages()}.
*
* @return array<int, string>
* Build a standard response payload envelope.
*/
public static function availableLanguages(): array
{
return ResponseService::availableLanguages();
public static function response(
mixed $data = null,
array $meta = []
): array {
return [
'data' => $data,
'meta' => $meta,
];
}
/**
* Standard meta block (`url`, `locale`, `languages` + extras).
* See {@see ResponseService::apiMeta()}.
*/
public static function apiMeta(array $extra = []): array
{
return ResponseService::apiMeta($extra);
}
/**
* Single-item envelope. See {@see ResponseService::apiItem()}.
*
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource>|null $resource_class
*/
public static function apiItem(mixed $item, ?string $resource_class = null, array $extraMeta = []): array
{
return ResponseService::apiItem($item, $resource_class, $extraMeta);
}
/**
* Non-paginated collection envelope.
* See {@see ResponseService::apiCollection()}.
*
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resource_class
*/
public static function apiCollection(iterable $items, string $resource_class, array $extraMeta = []): array
{
return ResponseService::apiCollection($items, $resource_class, $extraMeta);
}
/**
* Paginated envelope. See {@see ResponseService::apiPaginated()}.
*
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resource_class
*/
public static function apiPaginated(mixed $paginated, string $resource_class, array $extraMeta = []): array
{
return ResponseService::apiPaginated($paginated, $resource_class, $extraMeta);
}
/* ──────────────────────────────────────────────────────────────────────
* Misc utilities
* ────────────────────────────────────────────────────────────────────── */
/**
* Resolve a controller payload to a normalized options array.
*
* Supported payload shapes:
* - `['options' => [...]]`
* - `[...]` (already flat)
*
* @param array<string, mixed> $payload
* @param array<string, mixed> $defaults
* @return array<string, mixed>
* - ['options' => [...]]
* - [...] (already flat)
*/
public static function resolveOptions(array $payload, array $defaults = []): array
{
public static function resolveOptions(
array $payload,
array $defaults = []
): array {
$options = is_array($payload['options'] ?? null)
? $payload['options']
: $payload;
@ -98,12 +39,13 @@ class MiscService
}
/**
* Read an option value using exact / snake_case / camelCase fallback.
*
* @param array<string, mixed> $options
* Read an option value using camelCase or snake_case fallback.
*/
public static function option(array $options, string $key, mixed $default = null): mixed
{
public static function option(
array $options,
string $key,
mixed $default = null
): mixed {
if (array_key_exists($key, $options)) {
return $options[$key];
}
@ -122,9 +64,194 @@ class MiscService
}
/**
* Format a byte count as a human-readable string (B, KB, MB, ).
* Build pagination metadata in a consistent format.
*/
public static function bytesToHuman(int|float $bytes): string
public static function paginationMeta(
$paginated,
array $options = [],
array $meta = []
): array {
$data = $paginated->toArray();
$base = [
'from' => @$data['from'],
'to' => @$data['to'],
'total' => @$data['total'],
'last_page' => @$data['last_page'],
'current_page' => @$data['current_page'],
'options' => (object) $options,
];
if ($meta) {
$base = array_merge($base, $meta);
}
return $base;
}
public static function asPaginated(
$paginated,
$resource_class,
array $meta = [],
?array $options = null
) {
$resolvedOptions = $options;
if ($resolvedOptions === null) {
$resolvedOptions = is_array(request('options'))
? request('options')
: [];
}
$payload = [
'data' => $resource_class::collection($paginated),
'meta' => self::paginationMeta($paginated, $resolvedOptions),
];
if ($meta) {
$payload['meta'] = array_merge($payload['meta'], $meta);
}
return $payload;
}
/**
* Available content languages for the running app.
*
* Tries (in order):
* 1. config('languages.languages') Blax convention, list of {code, ...}
* 2. config('app.available_locales') plain array of codes
* 3. fall back to [app()->getLocale()]
*
* @return array<int, string>
*/
public static function availableLanguages(): array
{
$configured = config('languages.languages');
if (is_array($configured) && $configured) {
return collect($configured)
->map(fn($l) => is_array($l) ? ($l['code'] ?? $l['lang'] ?? null) : $l)
->filter()
->values()
->toArray();
}
$locales = config('app.available_locales');
if (is_array($locales) && $locales) {
return array_values($locales);
}
return [app()->getLocale()];
}
/**
* Standard meta block for an API response.
*
* Every api response in the workkit-shaped envelope carries this block
* so consumers always know:
* - which URL produced the payload (`url`)
* - which locale they got back (`locale`)
* - which other locales the same resource is available in (`languages`)
*
* Pagination keys (current_page, total, total_pages, etc.) are merged in
* by `apiPaginated()`; `apiItem()` / `apiCollection()` skip them.
*/
public static function apiMeta(array $extra = []): array
{
return array_merge([
'url' => optional(request())->fullUrl(),
'locale' => app()->getLocale(),
'languages' => self::availableLanguages(),
], $extra);
}
/**
* Paginated API envelope. Use for any list/index endpoint.
*
* Returns:
* {
* "data": [...resource collection...],
* "meta": {
* "url", "locale", "languages",
* "current_page", "per_page", "from", "to",
* "total", "total_pages", "has_more"
* }
* }
*/
public static function apiPaginated(
$paginated,
string $resource_class,
array $extraMeta = []
): array {
$arr = method_exists($paginated, 'toArray') ? $paginated->toArray() : [];
$current = $arr['current_page'] ?? 1;
$last = $arr['last_page'] ?? null;
$pagination = [
'current_page' => $current,
'per_page' => $arr['per_page'] ?? null,
'from' => $arr['from'] ?? null,
'to' => $arr['to'] ?? null,
'total' => $arr['total'] ?? null,
'total_pages' => $last,
'has_more' => ($last !== null) ? ($current < $last) : false,
];
return [
'data' => $resource_class::collection($paginated),
'meta' => self::apiMeta(array_merge($pagination, $extraMeta)),
];
}
/**
* Single-item API envelope. Use for any show endpoint.
*/
public static function apiItem(
$item,
?string $resource_class = null,
array $extraMeta = []
): array {
return [
'data' => $resource_class ? $resource_class::make($item) : $item,
'meta' => self::apiMeta($extraMeta),
];
}
/**
* Non-paginated collection envelope. Use only when pagination is
* impractical (tiny fixed list like an enum or a child collection of a
* parent show response). Most list endpoints should use apiPaginated().
*/
public static function apiCollection(
$items,
string $resource_class,
array $extraMeta = []
): array {
$count = is_countable($items) ? count($items) : null;
return [
'data' => $resource_class::collection($items),
'meta' => self::apiMeta(array_merge([
'total' => $count,
], $extraMeta)),
];
}
/**
* Plain envelope (data + meta) for arbitrary payloads login responses,
* action acknowledgements, etc. Always carries the standard apiMeta block.
*/
public static function apiResponse(
mixed $data = null,
array $extraMeta = []
): array {
return [
'data' => $data,
'meta' => self::apiMeta($extraMeta),
];
}
public static function bytesToHuman($bytes)
{
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
@ -135,53 +262,39 @@ class MiscService
return round($bytes, 2) . ' ' . $units[$i];
}
/**
* AES-128-ECB encrypt deterministic (same input same output).
* Use only where determinism is required; prefer Laravel's `Crypt`
* facade for general-purpose encryption.
*/
public static function deterministicEncrypt(string $data): string
public static function deterministicEncrypt($data)
{
return base64_encode(openssl_encrypt($data, 'AES-128-ECB', config('app.key'), OPENSSL_RAW_DATA));
}
/**
* Inverse of {@see deterministicEncrypt()}.
*/
public static function deterministicDecrypt(string $encrypted): string|false
public static function deterministicDecrypt($encrypted)
{
return openssl_decrypt(base64_decode($encrypted), 'AES-128-ECB', config('app.key'), OPENSSL_RAW_DATA);
}
/**
* Time a callable and log its duration at debug level. Returns the
* callable's return value (or null when no callable is given).
*/
public static function logExecutionTime(string $logtext, ?callable $callable = null): mixed
{
public static function logExecutionTime(
string $logtext,
$callable = null
) {
$start = microtime(true);
if (! $callable) {
return null;
if (!$callable) {
return;
}
$result = $callable();
$end = microtime(true);
$executionTime = $end - $start;
Log::debug($logtext, [
'execution_time' => $end - $start,
'execution_time' => $executionTime
]);
return $result;
}
/**
* Look up geolocation/ISP info for an IP via ipapi.co.
* Cached per-request via `once()` and per-IP via flexible cache.
*
* @return array<string, mixed>|null
*/
public static function getIpInformation(string $ip): ?array
public static function getIpInformation($ip)
{
return once(function () use ($ip) {
return cache()->flexible('ipapi-' . $ip, [60 * 60 * 24 * 2, 60 * 60 * 24 * 7], function () use ($ip) {
@ -196,10 +309,7 @@ class MiscService
});
}
/**
* Map a German/native country name to its ISO 3166-1 alpha-2 code.
*/
public static function countryToCode(string $country_long): ?string
public static function countryToCode($country_long): ?string
{
return match (str()->lower($country_long)) {
'deutschland' => 'de',
@ -213,11 +323,7 @@ class MiscService
};
}
/**
* Map an ISO 3166-1 alpha-2 code to a localized country name.
* Supports `de`, `es`, `uk` and falls through to English.
*/
public static function codeToCountry(string $country_code, ?string $locale = null): ?string
public static function codeToCountry($country_code, string|null $locale = null)
{
$country_code = str()->lower($country_code);
$locale ??= app()->getLocale();
@ -273,12 +379,10 @@ class MiscService
};
}
/**
* Parse partial/streaming JSON best-effort. Delegates to
* {@see IncompleteJsonService}.
*/
public static function parseIncompleteJson(string $json, bool $associative = true): array|object|null
{
public static function parseIncompleteJson(
string $json,
bool $associative = true
): array|object|null {
return (new IncompleteJsonService())->parse($json, $associative);
}
}

View File

@ -1,211 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Workkit\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Throwable;
/**
* Driver-agnostic queue health introspection, generalised from the learn-atc
* admin "Doctor" queue checks but stripped of app-specific bits (no Activity
* notices, no Redis heartbeat dependency, no job-payload decoding).
*
* For the `database` queue driver it reads the jobs table directly and reports,
* per queue: total pending, due (ready), delayed, reserved (in-flight) and the
* age of the oldest due job. From those it infers a STALLED queue due work
* piled up past the age threshold with nothing reserved which is the
* driver-agnostic proxy for "the worker is down" that needs no heartbeat.
*
* For other drivers per-queue introspection isn't available, so it degrades to
* the failed-jobs table only and marks per-queue stats unsupported.
*
* {@see evaluate()} is a pure function of (snapshot, thresholds) breaches, so
* the alarming logic is unit-testable without a database.
*/
class QueueHealthService
{
/**
* Inspect a queue connection.
*
* @return array{
* connection:string, driver:?string, supported:bool,
* queues:array<string, array{pending:int, due:int, delayed:int, reserved:int, oldest_due_age:?int}>,
* failed_total:?int, failed_recent:?int, failed_window_hours:int, now:int
* }
*/
public static function inspect(?string $connection = null, int $failedWindowHours = 24): array
{
$connection = $connection ?: config('queue.default');
$cfg = config("queue.connections.{$connection}", []);
$driver = $cfg['driver'] ?? null;
$now = time();
$snapshot = [
'connection' => (string) $connection,
'driver' => $driver,
'supported' => false,
'queues' => [],
'failed_total' => null,
'failed_recent' => null,
'failed_window_hours' => $failedWindowHours,
'errors' => [],
'now' => $now,
];
if ($driver === 'database') {
$dbConn = $cfg['connection'] ?? config('database.default');
$table = $cfg['table'] ?? 'jobs';
try {
// hasTable() === false is fine (a fresh app with no jobs table);
// only a thrown error means the database is actually unreachable,
// which must surface as a breach so cron doesn't read OK.
if (Schema::connection($dbConn)->hasTable($table)) {
$snapshot['supported'] = true;
$snapshot['queues'] = self::queueStats($dbConn, $table, $now);
}
} catch (Throwable $e) {
$snapshot['errors'][] = 'queue introspection failed: '.$e->getMessage();
}
}
// Failed jobs live in the DB regardless of the queue driver.
$failed = self::failedJobStats($failedWindowHours);
$snapshot['failed_total'] = $failed['total'];
$snapshot['failed_recent'] = $failed['recent'];
if ($failed['error'] !== null) {
$snapshot['errors'][] = $failed['error'];
}
return $snapshot;
}
/**
* Evaluate a snapshot against thresholds. Pure no I/O.
*
* @param array $snapshot output of {@see inspect()}
* @param array{max_depth:int, max_depth_per_queue:array<string,int>, max_age_minutes:int, max_failed:int} $thresholds
* @return array{ok:bool, breaches:array<int, array{type:string, queue:?string, message:string}>}
*/
public static function evaluate(array $snapshot, array $thresholds): array
{
$maxDepth = (int) ($thresholds['max_depth'] ?? 250);
$perQueue = (array) ($thresholds['max_depth_per_queue'] ?? []);
$maxAgeSec = (int) ($thresholds['max_age_minutes'] ?? 15) * 60;
$maxFailed = (int) ($thresholds['max_failed'] ?? 25);
$breaches = [];
foreach (($snapshot['queues'] ?? []) as $name => $q) {
$limit = array_key_exists($name, $perQueue) ? (int) $perQueue[$name] : $maxDepth;
if (($q['pending'] ?? 0) > $limit) {
$breaches[] = [
'type' => 'depth',
'queue' => $name,
'message' => sprintf("queue '%s' depth %d exceeds %d", $name, $q['pending'], $limit),
];
}
// Stalled: due work older than the age limit, nothing in flight.
$oldest = $q['oldest_due_age'] ?? null;
if (($q['due'] ?? 0) > 0 && (int) ($q['reserved'] ?? 0) === 0 && $oldest !== null && $oldest > $maxAgeSec) {
$breaches[] = [
'type' => 'stalled',
'queue' => $name,
'message' => sprintf(
"queue '%s' looks STALLED: oldest due job is %dm old with 0 reserved (worker down?)",
$name,
intdiv($oldest, 60),
),
];
}
}
$recent = $snapshot['failed_recent'] ?? null;
if ($recent !== null && $recent > $maxFailed) {
$breaches[] = [
'type' => 'failed',
'queue' => null,
'message' => sprintf('%d jobs failed in the last %dh (max %d)', $recent, (int) ($snapshot['failed_window_hours'] ?? 24), $maxFailed),
];
}
// A DB/connection error (not a merely-absent table) must surface as a
// breach so a monitoring probe exits non-zero instead of reading OK.
foreach (($snapshot['errors'] ?? []) as $err) {
$breaches[] = ['type' => 'error', 'queue' => null, 'message' => $err];
}
return ['ok' => $breaches === [], 'breaches' => $breaches];
}
/** Thresholds from config, each read with an explicit code-side default. */
public static function thresholds(): array
{
return [
'max_depth' => (int) config('workkit.queue.max_depth', 250),
'max_depth_per_queue' => (array) config('workkit.queue.max_depth_per_queue', []),
'max_age_minutes' => (int) config('workkit.queue.max_age_minutes', 15),
'max_failed' => (int) config('workkit.queue.max_failed', 25),
];
}
// ---- internals ---------------------------------------------------------
/**
* Per-queue conditional aggregates in a single grouped query (works on
* MySQL + SQLite + Postgres; jobs timestamps are unix ints).
*
* @return array<string, array{pending:int, due:int, delayed:int, reserved:int, oldest_due_age:?int}>
*/
private static function queueStats(string $dbConn, string $table, int $now): array
{
$rows = DB::connection($dbConn)->table($table)
->selectRaw('queue')
->selectRaw('COUNT(*) as pending')
->selectRaw('SUM(CASE WHEN reserved_at IS NOT NULL THEN 1 ELSE 0 END) as reserved')
->selectRaw('SUM(CASE WHEN reserved_at IS NULL AND available_at <= ? THEN 1 ELSE 0 END) as due', [$now])
->selectRaw('SUM(CASE WHEN reserved_at IS NULL AND available_at > ? THEN 1 ELSE 0 END) as delayed', [$now])
->selectRaw('MIN(CASE WHEN reserved_at IS NULL AND available_at <= ? THEN available_at END) as oldest_due', [$now])
->groupBy('queue')
->get();
$out = [];
foreach ($rows as $r) {
$oldest = $r->oldest_due;
$out[(string) $r->queue] = [
'pending' => (int) $r->pending,
'due' => (int) $r->due,
'delayed' => (int) $r->delayed,
'reserved' => (int) $r->reserved,
'oldest_due_age' => $oldest === null ? null : max(0, $now - (int) $oldest),
];
}
return $out;
}
/**
* @return array{total:?int, recent:?int, error:?string}
*/
private static function failedJobStats(int $windowHours): array
{
$dbConn = config('queue.failed.database') ?: config('database.default');
$table = config('queue.failed.table', 'failed_jobs');
try {
if (! Schema::connection($dbConn)->hasTable($table)) {
return ['total' => null, 'recent' => null, 'error' => null];
}
$total = DB::connection($dbConn)->table($table)->count();
$since = date('Y-m-d H:i:s', time() - $windowHours * 3600);
$recent = DB::connection($dbConn)->table($table)->where('failed_at', '>=', $since)->count();
return ['total' => $total, 'recent' => $recent, 'error' => null];
} catch (Throwable $e) {
return ['total' => null, 'recent' => null, 'error' => 'failed-jobs query failed: '.$e->getMessage()];
}
}
}

View File

@ -1,320 +0,0 @@
<?php
namespace Blax\Workkit\Services;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
/**
* API response envelope builder.
*
* Every helper here success or error produces the same wire shape:
*
* {
* "status": { "code": <int>, "text": <reason phrase> },
* "message": <human readable | null>,
* "data": <payload | absent>, // success only
* "error": <{ type, ... } | absent>, // error only
* "errors": <{ field: [...] } | absent>, // present for validation errors
* "meta": { url, locale, languages, ...pagination... }
* }
*
* The split is: `data` carries success payloads, `error` carries failure
* details, `errors` is the Laravel-compatible field-map alias so
* `assertJsonValidationErrors([...])` keeps working without re-jiggering tests.
* Every response also reports its HTTP status as both code and reason phrase
* inside the body useful for clients that can't easily inspect headers.
*
* Controller lifecycle under {@see \Blax\Workkit\Middleware\ForceJsonResponse}:
*
* 200 OK return ResponseService::apiItem(...) (plain array)
* 200 OK list return ResponseService::apiPaginated($q->paginate(...), ...)
* 201 Created return ResponseService::apiCreated(...) (JsonResponse)
* 202 Accepted return ResponseService::apiAccepted(...) (JsonResponse)
* 204 No Content return ResponseService::apiNoContent() (JsonResponse)
* 4xx/5xx return ResponseService::apiError(...) (JsonResponse)
* 422 validation return ResponseService::apiValidationError([...]) (JsonResponse)
*
* Reach for `response()->json(...)` directly only if you genuinely need a
* status code or shape not modeled above in which case prefer to add a
* helper here so the convention stays uniform.
*/
class ResponseService
{
/* ─────────────────────────── building blocks ──────────────────────── */
/**
* Available content languages for the running app.
*
* Resolution order:
* 1. `config('languages.languages')` Blax convention, list of
* `{ code, ... }` records.
* 2. `config('app.available_locales')` plain array of codes.
* 3. Fallback to `[app()->getLocale()]`.
*
* @return array<int, string>
*/
public static function availableLanguages(): array
{
$configured = config('languages.languages');
if (is_array($configured) && $configured) {
return collect($configured)
->map(fn ($l) => is_array($l) ? ($l['code'] ?? $l['lang'] ?? null) : $l)
->filter()
->values()
->toArray();
}
$locales = config('app.available_locales');
if (is_array($locales) && $locales) {
return array_values($locales);
}
return [app()->getLocale()];
}
/**
* Standard meta block: `url`, `locale`, `languages`, plus any extras
* (the per-helper additions like pagination keys land here).
*
* @param array<string, mixed> $extra
* @return array<string, mixed>
*/
public static function apiMeta(array $extra = []): array
{
return array_merge([
'url' => optional(request())->fullUrl(),
'locale' => app()->getLocale(),
'languages' => self::availableLanguages(),
], $extra);
}
/**
* Internal envelope builder used by every success/error helper. Keeping
* a single source of truth here means the wire shape can't drift between
* helpers a new top-level key only needs to be added in one place.
*
* @param array<string, mixed> $payload Body keys (data/error/errors)
* @param array<string, mixed> $extraMeta
* @return array<string, mixed>
*/
private static function envelope(
int $statusCode,
?string $message,
array $payload,
array $extraMeta = [],
): array {
return array_merge([
'status' => [
'code' => $statusCode,
'text' => Response::$statusTexts[$statusCode] ?? 'Unknown',
],
'message' => $message,
], $payload, [
'meta' => self::apiMeta($extraMeta),
]);
}
/* ─────────────────────────────── success ──────────────────────────── */
/**
* Single-item envelope. Use for any `show`-style endpoint or for an
* arbitrary payload (login receipt, ack, etc. pass `null` resource).
*
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource>|null $resourceClass
* @param array<string, mixed> $extraMeta
* @return array{status: array{code:int,text:string}, message: ?string, data: mixed, meta: array<string, mixed>}
*/
public static function apiItem(
mixed $item,
?string $resourceClass = null,
array $extraMeta = [],
?string $message = null,
): array {
return self::envelope(200, $message, [
'data' => $resourceClass !== null ? $resourceClass::make($item) : $item,
], $extraMeta);
}
/**
* Non-paginated collection envelope. Reserve for genuinely tiny fixed
* lists (an enum, a child collection embedded in a parent response).
* Most list endpoints should use {@see apiPaginated()}.
*
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
* @param array<string, mixed> $extraMeta
* @return array<string, mixed>
*/
public static function apiCollection(
iterable $items,
string $resourceClass,
array $extraMeta = [],
?string $message = null,
): array {
$count = is_countable($items) ? count($items) : null;
return self::envelope(200, $message, [
'data' => $resourceClass::collection($items),
], array_merge(['total' => $count], $extraMeta));
}
/**
* Paginated envelope. Use for any `index`-style endpoint. The meta block
* picks up the paginator's `current_page`, `per_page`, `from`, `to`,
* `total`, `total_pages` / `last_page` (aliased so legacy clients keep
* working) and a `has_more` boolean.
*
* @param \Illuminate\Contracts\Pagination\Paginator|mixed $paginated
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource> $resourceClass
* @param array<string, mixed> $extraMeta
* @return array<string, mixed>
*/
public static function apiPaginated(
mixed $paginated,
string $resourceClass,
array $extraMeta = [],
?string $message = null,
): array {
$arr = method_exists($paginated, 'toArray') ? $paginated->toArray() : [];
$current = $arr['current_page'] ?? 1;
$last = $arr['last_page'] ?? null;
return self::envelope(200, $message, [
'data' => $resourceClass::collection($paginated),
], array_merge([
'current_page' => $current,
'per_page' => $arr['per_page'] ?? null,
'from' => $arr['from'] ?? null,
'to' => $arr['to'] ?? null,
'total' => $arr['total'] ?? null,
'total_pages' => $last,
'last_page' => $last,
'has_more' => $last !== null && $current < $last,
], $extraMeta));
}
/**
* Single-item envelope wrapped in a 201 Created JsonResponse. Use for
* any `store`-style endpoint.
*
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource>|null $resourceClass
* @param array<string, mixed> $extraMeta
*/
public static function apiCreated(
mixed $item,
?string $resourceClass = null,
array $extraMeta = [],
?string $message = null,
): JsonResponse {
return response()->json(
self::envelope(201, $message, [
'data' => $resourceClass !== null ? $resourceClass::make($item) : $item,
], $extraMeta),
Response::HTTP_CREATED,
);
}
/**
* 202 Accepted envelope for endpoints that queue work and return a
* receipt rather than the final resource.
*
* @param class-string<\Illuminate\Http\Resources\Json\JsonResource>|null $resourceClass
* @param array<string, mixed> $extraMeta
*/
public static function apiAccepted(
mixed $item = null,
?string $resourceClass = null,
array $extraMeta = [],
?string $message = null,
): JsonResponse {
return response()->json(
self::envelope(202, $message, [
'data' => $resourceClass !== null ? $resourceClass::make($item) : $item,
], $extraMeta),
Response::HTTP_ACCEPTED,
);
}
/**
* 204 No Content carries no body. Useful for `delete`-style endpoints.
*/
public static function apiNoContent(): JsonResponse
{
return response()->json(null, Response::HTTP_NO_CONTENT);
}
/* ──────────────────────────────── errors ──────────────────────────── */
/**
* Generic error envelope. Accepts either:
*
* - A {@see \Throwable} instance its class becomes `error.type` and
* its message becomes the envelope `message` (both overridable).
* - A plain string used as the envelope `message`. `type` defaults
* to `'Error'` unless you pass it explicitly.
*
* return ResponseService::apiError($e, 422, ['book' => ['...']]);
* return ResponseService::apiError('Forbidden', 403);
* return ResponseService::apiError('Rate limited', 429, type: 'TooManyRequests');
*
* @param array<string, array<int, string>> $errors Field-keyed
* validation-style errors, mirrored into the top-level `errors`
* key for {@see \Illuminate\Testing\TestResponse::assertJsonValidationErrors()}.
* @param array<string, mixed> $extraMeta
*/
public static function apiError(
Throwable|string $errorOrMessage,
int $status = 500,
array $errors = [],
?string $type = null,
?string $message = null,
array $extraMeta = [],
): JsonResponse {
if ($errorOrMessage instanceof Throwable) {
$type ??= class_basename($errorOrMessage);
$message ??= $errorOrMessage->getMessage();
} else {
$message ??= $errorOrMessage;
$type ??= 'Error';
}
$payload = ['error' => ['type' => $type]];
if (! empty($errors)) {
$payload['errors'] = $errors;
}
return response()->json(
self::envelope($status, $message, $payload, $extraMeta),
$status,
);
}
/**
* 422 Unprocessable Entity wrapper around {@see apiError()} with the
* field-error map pre-filled. Mirrors the shape Laravel's default
* ValidationException renderer produces, so `assertJsonValidationErrors`
* keeps working but the envelope also carries the unified `status`,
* `message`, `error` keys for the rest of the body.
*
* return ResponseService::apiValidationError([
* 'book' => ['No copies of this book are currently available.'],
* ]);
*
* @param array<string, array<int, string>> $errors
* @param array<string, mixed> $extraMeta
*/
public static function apiValidationError(
array $errors,
?string $message = null,
array $extraMeta = [],
): JsonResponse {
return self::apiError(
errorOrMessage: $message ?? 'The given data was invalid.',
status: Response::HTTP_UNPROCESSABLE_ENTITY,
errors: $errors,
type: 'ValidationException',
extraMeta: $extraMeta,
);
}
}

View File

@ -1,292 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Workkit\Services;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use ReflectionClass;
use Throwable;
/**
* Read-only database/system stats for an at-a-glance "admin overview" in the
* terminal: per-model row counts, per-table sizes, and the total database
* footprint. Everything here is best-effort and never mutates data.
*
* Row counts come in two flavours:
* - estimate (default, MySQL): information_schema.TABLE_ROWS instant, but
* for InnoDB it's an approximation that drifts from the true count.
* - exact: COUNT(*) per table accurate but a full scan on huge tables.
*
* Table/database SIZE figures are MySQL-only (information_schema). On other
* drivers the size columns come back null and counts fall back to exact.
*/
class StatsService
{
/** Per-process memo so the information_schema scan runs once per connection. */
private static array $tableStatsCache = [];
/**
* Per-table stats for a connection's database, largest first. Memoised per
* connection for the life of the process (these commands take a single
* snapshot, and databaseSize/estimateMap both reuse it).
*
* @return array<int, array{table:string, rows:?int, bytes:?int, engine:?string}>
*/
public static function tableStats(?string $connection = null): array
{
$connection = $connection ?: config('database.default');
if (array_key_exists($connection, self::$tableStatsCache)) {
return self::$tableStatsCache[$connection];
}
if (! self::usesInformationSchema($connection)) {
// No information_schema equivalent we rely on — fall back to exact
// counts per known table, no size data.
return self::$tableStatsCache[$connection] = self::tableStatsFallback($connection);
}
$rows = DB::connection($connection)->select(
'SELECT table_name AS t, table_rows AS r, '
.'(data_length + index_length) AS b, engine AS e '
.'FROM information_schema.tables '
.'WHERE table_schema = DATABASE() AND table_type = ? '
.'ORDER BY b DESC',
['BASE TABLE']
);
return self::$tableStatsCache[$connection] = array_map(fn ($row) => [
'table' => (string) $row->t,
'rows' => $row->r === null ? null : (int) $row->r,
'bytes' => $row->b === null ? null : (int) $row->b,
'engine' => $row->e === null ? null : (string) $row->e,
], $rows);
}
/**
* Total database size + table count for a connection. On MySQL/MariaDB
* `tables` is the true BASE TABLE count and `bytes` the real total; on
* other drivers `bytes` is null and `tables` reflects only the discovered
* model tables (the fallback can't enumerate the full schema).
*
* @return array{bytes:?int, tables:int}
*/
public static function databaseSize(?string $connection = null): array
{
$connection = $connection ?: config('database.default');
$stats = self::tableStats($connection);
$bytes = 0;
$haveBytes = false;
foreach ($stats as $s) {
if ($s['bytes'] !== null) {
$bytes += $s['bytes'];
$haveBytes = true;
}
}
return [
'bytes' => $haveBytes ? $bytes : null,
'tables' => count($stats),
];
}
/**
* Row count per Eloquent model.
*
* Models are taken from config('workkit.stats.models') when set (an
* explicit FQCN list), otherwise auto-discovered by scanning
* config('workkit.stats.models_path') (default: app_path('Models')).
*
* @param string[]|null $models explicit class list (overrides discovery)
* @return array<int, array{model:string, table:string, count:?int, estimated:bool, error:?string}>
*/
public static function modelCounts(?array $models = null, bool $exact = false, ?string $connection = null): array
{
$models = $models ?? self::discoverModels();
$mapConn = $connection ?: config('database.default');
$estimates = (! $exact && self::usesInformationSchema($mapConn))
? self::estimateMap($mapConn)
: [];
$out = [];
foreach ($models as $class) {
$row = ['model' => self::shortName($class), 'table' => '', 'count' => null, 'estimated' => false, 'error' => null];
try {
/** @var Model $instance */
$instance = new $class;
$conn = $connection ?: $instance->getConnectionName() ?: config('database.default');
$table = $instance->getTable();
$row['table'] = $table;
// Only trust the estimate map for the SAME connection it was
// built from, key it with that connection's table prefix, and
// skip a null TABLE_ROWS (empty / never-analysed table) so it
// falls through to an exact count instead of reporting a bogus
// (or cross-database) value.
$prefixed = DB::connection($conn)->getTablePrefix().$table;
if (! $exact && $conn === $mapConn
&& array_key_exists($prefixed, $estimates)
&& $estimates[$prefixed] !== null) {
$row['count'] = $estimates[$prefixed];
$row['estimated'] = true;
} else {
$row['count'] = DB::connection($conn)->table($table)->count();
}
} catch (Throwable $e) {
$row['error'] = $e->getMessage();
}
$out[] = $row;
}
// Largest first; un-countable (error) rows sink to the bottom.
usort($out, fn ($a, $b) => ($b['count'] ?? -1) <=> ($a['count'] ?? -1));
return $out;
}
/**
* Discover instantiable Eloquent model classes under the models path.
*
* @return string[] fully-qualified class names
*/
public static function discoverModels(): array
{
$explicit = config('workkit.stats.models');
if (is_array($explicit) && $explicit !== []) {
return array_values(array_filter($explicit, 'is_string'));
}
$path = config('workkit.stats.models_path');
if (! $path && function_exists('app_path')) {
$path = app_path('Models');
}
if (! $path || ! is_dir($path)) {
return [];
}
$found = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if (! $file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$class = self::classFromFile((string) $file->getPathname());
if ($class === null || ! class_exists($class)) {
continue;
}
try {
$ref = new ReflectionClass($class);
} catch (Throwable $e) {
continue;
}
if ($ref->isAbstract() || ! $ref->isInstantiable() || ! $ref->isSubclassOf(Model::class)) {
continue;
}
$found[$class] = true;
}
$classes = array_keys($found);
sort($classes);
return $classes;
}
/** Human byte size — delegates to BackupService for one implementation. */
public static function humanBytes(int $bytes): string
{
return BackupService::humanBytes($bytes);
}
// ---- internals ---------------------------------------------------------
/**
* MySQL and MariaDB expose the same information_schema.tables columns we
* query (table_rows, data_length, index_length, engine), so both take the
* fast/size-aware path. Laravel 11+ ships 'mariadb' as a distinct driver.
*/
private static function usesInformationSchema(string $connection): bool
{
return in_array(
config("database.connections.{$connection}.driver"),
['mysql', 'mariadb'],
true
);
}
/** table => estimated row count, from information_schema (MySQL). */
private static function estimateMap(string $connection): array
{
$map = [];
foreach (self::tableStats($connection) as $s) {
$map[$s['table']] = $s['rows'];
}
return $map;
}
/**
* Non-MySQL fallback: exact COUNT per discovered model's table (no sizes).
*
* @return array<int, array{table:string, rows:?int, bytes:?int, engine:?string}>
*/
private static function tableStatsFallback(string $connection): array
{
$seen = [];
$out = [];
foreach (self::discoverModels() as $class) {
try {
$instance = new $class;
$table = $instance->getTable();
if (isset($seen[$table])) {
continue;
}
$seen[$table] = true;
$out[] = [
'table' => $table,
'rows' => DB::connection($connection)->table($table)->count(),
'bytes' => null,
'engine' => null,
];
} catch (Throwable $e) {
// skip un-countable model
}
}
usort($out, fn ($a, $b) => ($b['rows'] ?? -1) <=> ($a['rows'] ?? -1));
return $out;
}
/** Parse the FQCN from a PHP file via its namespace + class declaration. */
public static function classFromFile(string $path): ?string
{
$src = @file_get_contents($path);
if ($src === false) {
return null;
}
if (! preg_match('/^\s*namespace\s+([^;]+);/m', $src, $ns)) {
return null;
}
if (! preg_match_all('/^\s*(?:final\s+|abstract\s+)*class\s+(\w+)/m', $src, $all)) {
return null;
}
// Prefer the class whose name matches the file (PSR-4 expectation), so
// a file with a base/abstract class declared before the real model
// doesn't hide it; otherwise fall back to the first declared class.
$expected = pathinfo($path, PATHINFO_FILENAME);
$chosen = in_array($expected, $all[1], true) ? $expected : $all[1][0];
return trim($ns[1]).'\\'.$chosen;
}
private static function shortName(string $class): string
{
$pos = strrpos($class, '\\');
return $pos === false ? $class : substr($class, $pos + 1);
}
}

View File

@ -2,17 +2,10 @@
namespace Blax\Workkit;
use Blax\Workkit\Attributes\VariablePaginatable;
use Blax\Workkit\Commands\Database\BackupCommand;
use Blax\Workkit\Commands\Database\PruneBackupsCommand;
use Blax\Workkit\Commands\Database\RestoreCommand;
use Blax\Workkit\Commands\Database\StatsCommand;
use Blax\Workkit\Commands\Database\VerifyCommand;
use Blax\Workkit\Commands\PlugNPrayCommand;
use Blax\Workkit\Commands\Queue\QueueHealthCommand;
use Illuminate\Http\Request;
use ReflectionException;
use ReflectionMethod;
class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider
{
@ -23,7 +16,7 @@ class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider
*/
public function register()
{
$this->mergeConfigFrom(__DIR__.'/../config/workkit.php', 'workkit');
$this->mergeConfigFrom(__DIR__ . '/../config/workkit.php', 'workkit');
}
/**
@ -33,70 +26,19 @@ class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider
*/
public function boot()
{
$this->registerPerPageMacro();
if ($this->app->runningInConsole()) {
$this->commands([
PlugNPrayCommand::class,
BackupCommand::class,
RestoreCommand::class,
VerifyCommand::class,
PruneBackupsCommand::class,
StatsCommand::class,
QueueHealthCommand::class,
]);
// Hosts that want to override path / retention publish the
// config; otherwise mergeConfigFrom() above provides defaults.
$this->publishes([
__DIR__.'/../config/workkit.php' => $this->app->configPath('workkit.php'),
__DIR__ . '/../config/workkit.php' => $this->app->configPath('workkit.php'),
], 'workkit-config');
}
}
/**
* Resolve the effective page size for the current route via the
* {@see VariablePaginatable} attribute on the controller method.
*
* Order of resolution:
* 1. No route / closure action / no attribute $fallback (default 15)
* 2. Attribute present, allowUserOverride=true clamp `?per_page=N`
* into `[1, max]`, defaulting to `default` when the query is missing.
* 3. Attribute present, allowUserOverride=false `default` (ignores query).
*/
private function registerPerPageMacro(): void
{
Request::macro('perPage', function (int $fallback = 15): int {
/** @var Request $this */
$route = $this->route();
$controller = is_object($route?->getController()) ? $route->getController()::class : null;
$action = is_string($route?->getActionMethod()) ? $route->getActionMethod() : null;
if (! $controller || ! $action) {
return $fallback;
}
try {
$reflection = new ReflectionMethod($controller, $action);
} catch (ReflectionException) {
return $fallback;
}
$attributes = $reflection->getAttributes(VariablePaginatable::class);
if ($attributes === []) {
return $fallback;
}
/** @var VariablePaginatable $config */
$config = $attributes[0]->newInstance();
if (! $config->allowUserOverride) {
return $config->default;
}
$requested = (int) $this->query('per_page', (string) $config->default);
return min(max($requested, 1), $config->max);
});
}
}