Compare commits
1 Commits
master
...
fix/backup
| Author | SHA1 | Date |
|---|---|---|
|
|
3dca9978d9 |
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue