Compare commits
1 Commits
master
...
fix/backup
| Author | SHA1 | Date |
|---|---|---|
|
|
3dca9978d9 |
85
CHANGELOG.md
85
CHANGELOG.md
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 -->
|
||||
[](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 -->
|
||||
[](https://php.net)
|
||||
[](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
199
README.md
|
|
@ -2,203 +2,4 @@
|
|||
|
||||
# Laravel Workkit
|
||||
|
||||
[](https://php.net)
|
||||
[](https://laravel.com)
|
||||
[](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 (0–9) |
|
||||
|
||||
### Requirements
|
||||
|
||||
The host needs `mysqldump`, `mysql`, `xz`, `openssl` and `bash` on `PATH` —
|
||||
standard on any reasonable Linux server. A non-empty `APP_KEY` is required
|
||||
(backups are unrecoverable without the key that produced them).
|
||||
|
||||
### Restoring without the package
|
||||
|
||||
The output is plain `openssl enc -salt` format, so any host with the same
|
||||
`APP_KEY` can restore it directly:
|
||||
|
||||
```bash
|
||||
openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 -pass env:WK_KEY \
|
||||
-in db_mysql_2026-06-28_09-21-49.sql.xz.enc | xz -d | mysql <database>
|
||||
# WK_KEY = your APP_KEY with the "base64:" prefix stripped
|
||||
```
|
||||
|
||||
## Ops & Observability
|
||||
|
||||
### Stats overview
|
||||
|
||||
```bash
|
||||
php artisan workkit:stats # models + largest tables + DB size + queue/backup summary
|
||||
php artisan workkit:stats --exact # true COUNT(*) per table (heavier) instead of the estimate
|
||||
php artisan workkit:stats --models --json # just per-model counts, machine-readable
|
||||
php artisan workkit:stats --tables --limit=30
|
||||
```
|
||||
|
||||
Model row counts are **estimated** from `information_schema` on MySQL (instant);
|
||||
pass `--exact` for a real `COUNT(*)`. Models are auto-discovered from
|
||||
`app/Models` — override with `config('workkit.stats.models')` (an explicit FQCN
|
||||
list) or `models_path`. Table/DB **sizes** are MySQL-only and degrade to "n/a"
|
||||
elsewhere.
|
||||
|
||||
### Queue health
|
||||
|
||||
```bash
|
||||
php artisan workkit:queue:health # human table; exits 0 / non-zero
|
||||
php artisan workkit:queue:health --json || notify-ops # cron / monitoring probe
|
||||
php artisan workkit:queue:health --max-age=10 --max-depth=500 --max-failed=50
|
||||
```
|
||||
|
||||
For the `database` queue driver it reports per-queue **pending / due / delayed /
|
||||
reserved** counts and the **oldest due job's age**, plus failed jobs (total +
|
||||
last *N* hours). It flags a queue **STALLED** — due work older than
|
||||
`max_age_minutes` with nothing reserved (the worker is probably down) — and
|
||||
**exits non-zero on any breach** so it slots straight into cron or a health
|
||||
probe. Thresholds live under `config('workkit.queue.*')`, with per-queue depth
|
||||
overrides for intentionally-slow queues. Schedule it:
|
||||
|
||||
```php
|
||||
Schedule::command('workkit:queue:health')->everyFiveMinutes();
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=blax-software%2Flaravel-workkit&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=blax-software/laravel-workkit&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=blax-software/laravel-workkit&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=blax-software/laravel-workkit&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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 0–9 (default: 3 — fast, ~10× ratio for SQL)}
|
||||
{--verify : After writing, verify the backup decrypts + is a valid xz stream}';
|
||||
{--xz-level= : xz compression level 0–9 (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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
));
|
||||
if (! $this->option('force') && ! $this->confirm('Proceed?', false)) {
|
||||
$this->info('Aborted.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
$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);
|
||||
|
||||
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.');
|
||||
$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;
|
||||
}
|
||||
|
||||
$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];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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]];
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,26 +52,26 @@ 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';
|
||||
$xz = "xz -{$level} -T0";
|
||||
$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, [
|
||||
'MYSQL_PWD' => (string) ($cfg['password'] ?? ''),
|
||||
'WK_KEY' => self::passphrase(),
|
||||
'WK_KEY' => self::passphrase(),
|
||||
]);
|
||||
} catch (RuntimeException $e) {
|
||||
// Don't leave a half-written encrypted file lying around —
|
||||
|
|
@ -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,235 +100,38 @@ 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'] ?? ''),
|
||||
'WK_KEY' => self::passphrase(),
|
||||
'WK_KEY' => self::passphrase(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue