Compare commits

..

1 Commits

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
return;
}
$result = $callable();
$end = microtime(true);
$executionTime = $end - $start;
Log::debug($logtext, [
'execution_time' => $end - $start,
'execution_time' => $executionTime
]);
return $result;
}
/**
* Look up geolocation/ISP info for an IP via ipapi.co.
* Cached per-request via `once()` and per-IP via flexible cache.
*
* @return array<string, mixed>|null
*/
public static function getIpInformation(string $ip): ?array
public static function getIpInformation($ip)
{
return once(function () use ($ip) {
return cache()->flexible('ipapi-' . $ip, [60 * 60 * 24 * 2, 60 * 60 * 24 * 7], function () use ($ip) {
@ -196,10 +309,7 @@ class MiscService
});
}
/**
* Map a German/native country name to its ISO 3166-1 alpha-2 code.
*/
public static function countryToCode(string $country_long): ?string
public static function countryToCode($country_long): ?string
{
return match (str()->lower($country_long)) {
'deutschland' => 'de',
@ -213,11 +323,7 @@ class MiscService
};
}
/**
* Map an ISO 3166-1 alpha-2 code to a localized country name.
* Supports `de`, `es`, `uk` and falls through to English.
*/
public static function codeToCountry(string $country_code, ?string $locale = null): ?string
public static function codeToCountry($country_code, string|null $locale = null)
{
$country_code = str()->lower($country_code);
$locale ??= app()->getLocale();
@ -273,12 +379,10 @@ class MiscService
};
}
/**
* Parse partial/streaming JSON best-effort. Delegates to
* {@see IncompleteJsonService}.
*/
public static function parseIncompleteJson(string $json, bool $associative = true): array|object|null
{
public static function parseIncompleteJson(
string $json,
bool $associative = true
): array|object|null {
return (new IncompleteJsonService())->parse($json, $associative);
}
}

View File

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

View File

@ -2,14 +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\PlugNPrayCommand;
use Illuminate\Http\Request;
use ReflectionException;
use ReflectionMethod;
class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider
{
@ -30,8 +26,6 @@ class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider
*/
public function boot()
{
$this->registerPerPageMacro();
if ($this->app->runningInConsole()) {
$this->commands([
PlugNPrayCommand::class,
@ -47,50 +41,4 @@ class WorkkitServiceProvider extends \Illuminate\Support\ServiceProvider
], '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);
});
}
}