Local Development
What this covers / who it's for. The local dev environment: the Docker stack and its services/ports, the make-first workflow, MinIO-backed object storage, Xdebug, and the concurrency caveats that bite parallel sessions. Everything here runs from
stables-core-app/and everything executes inside Docker.
The make-first rule
Every common dev task has a Makefile target that wraps the right docker compose
exec/run invocation — reach for make before raw docker/artisan/composer.
make help lists all targets grouped by category; make status shows containers, URLs, and key
config at a glance. When you discover a genuinely reusable command with no target, add one (with
a ## help description) in the same change.
make setup # first time: .env from .env.example + APP_KEY + build + start
make up # start all services (detached); make up-fg for foreground logs
make down # stop (volumes preserved); make down-clean wipes volumes
make rebuild # --no-cache rebuild + recreate containers
.env is per-developer and gitignored; .env.example is the committed template with the Docker
service hostnames already filled in. make setup creates it; manually:
cp .env.example .env && make artisan ARGS="key:generate".
Services & ports
Host ports follow the 1XXXX scheme — prepend 1 to the standard port. Inside the Docker
network, containers use the unchanged internal ports (the app reaches postgres on 5432).
| Service | Host URL / port | Purpose |
|---|---|---|
app | http://localhost:18000 | Laravel web server (php artisan serve) |
mailpit | http://localhost:18025 (UI), 11025 (SMTP) | Mail catcher |
postgres | localhost:15432 | PostgreSQL 18 |
valkey | localhost:16379 | Valkey (Redis-compatible) cache/queue |
minio | http://localhost:19001 (console), 19000 (S3 API) | S3-compatible object storage |
vendor-mock | http://localhost:18080 (WireMock admin) | Generated fake-vendor surface (Sumsub first) — the default-on local KYC/KYB mock; drive it with php artisan vendor-mock:emit. See the vendor-mock-development skill |
worker | — (no HTTP) | queue:work --tries=3 --timeout=90 — consumes the default queue only |
scheduler | — (no HTTP) | schedule:work — drives all scheduled commands |
minio-init | — (one-shot) | Creates the dev bucket then exits — Exited (0) is normal |
Two runtime notes worth internalizing:
- The local worker only consumes
default. The specialized queues (webhooks,documents,audit,dlp,tasks) are consumed locally only by running Horizon (make artisan ARGS=horizon) — see Observability & ops for why a non-consumed queue is a silent failure. - MinIO backs the env-driven
s3disk (AWS_ENDPOINT=http://minio:9000, bucketstables-local— see.env.example). Helpers:make shell-s3(anmcshell with thelocalalias preconfigured),make s3-reset(drop + recreate the bucket),make logs-s3. Integration tests that need the real S3 driver skip themselves when MinIO is unreachable.
Key targets by group
| Group | Targets |
|---|---|
| Stack | setup up up-fg up-xdebug down down-clean restart(-app/-worker/-scheduler) ps status build rebuild |
| Logs | logs logs-app logs-worker logs-scheduler logs-db logs-cache logs-s3 pail |
| Shells | shell shell-worker shell-scheduler shell-db (psql) shell-cache (valkey-cli) shell-s3 (mc) |
| Artisan/Composer | artisan ARGS="..." tinker routes composer ARGS="..." composer-install |
| DB & migrations | migrate migrate-fresh migrate-rollback migrate-status seed seed-verify test-db — the migrate targets also apply to the test DB and regenerate RLS policies |
| Tests & gates | test-parallel test test-filter FILTER=... test-coverage test-kill lint lint-check stan — see Testing & the gates |
| Queue & cache | queue-failed queue-retry queue-flush cache-clear config-clear |
| API artifacts | openapi-export abilities-export |
| Audit ops | audit-verify-chain audit-export-worm audit-verify-worm |
| Vendor mock | vendor-mock-generate vendor-mock-up test-vendor-mock |
| Live vendor | sumsub-live sumsub-mock tunnel sandbox-tenant vendor-logs test-sumsub-sandbox — see below |
Seeding is idempotent. migrate --seed works against a fresh database and is safe to re-run.
make seed-verify runs db:seed twice against the real dev-schema database — proving it neither
crashes nor trips a constraint on a re-run — and CI runs it right after make migrate, so a seeder
regression can no longer reach a deploy unnoticed; the stronger claim that a re-seed changes no row
COUNTS is proved in the suite by tests/Feature/DatabaseSeederTest.php. The test@example.com /
password developer login is seeded in local/testing only. It holds no role, so it can
sign in (operator login checks credentials + account status, never a role, and the seeded user has no
MFA) and reach the self-service surfaces that require authentication but no permission — /me,
/me/abilities, logout, MFA enrolment — while passing no backoffice permission gate. For an operator
that can actually use the backoffice, run make artisan ARGS="stables:create-super-admin".
Testing against a real vendor (Sumsub sandbox)
Vendor calls are faked by default — the vendor-mock WireMock service locally, Saloon's MockClient in
the suite. Neither can tell you whether the request you send is one the vendor accepts, only that it was
well-formed enough to match a stub. When that is the question, flip to a real account:
make sumsub-live splices the git-ignored .env.sumsub-live overlay (templated by
.env.sumsub-live.example) into .env and restarts the app; make sumsub-mock restores the offline
defaults. It refuses a half-filled overlay and refuses mismatched webhook secrets, because both fail in
ways that look like a signing bug. make status prints the active mode — a live-vendor session is
easy to leave switched on, and the failure is silent.
The rest of the loop: make tunnel (cloudflared) gives the vendor a public URL to POST webhooks to —
needed because nothing else can reach your machine. It exposes the whole app, not just the webhook
route: a quick tunnel forwards the entire origin and cannot be scoped to a path, so every
unauthenticated route and the seeded default credentials (test@example.com / password, and any
sandbox tenant) are internet-reachable while it runs, with APP_DEBUG on. The target prints this
warning too — stop the tunnel as soon as the test is done. make sandbox-tenant creates a GB tenant whose
jurisdiction actually routes to Sumsub (it refuses countries with no grant, which is what an unseeded
jurisdictions table looks like); make vendor-logs decrypts vendor_request_logs so a real 4xx body is
readable; make test-sumsub-sandbox runs the opt-in live spike. The worker container must be up or
inbound webhooks are stored and never processed.
Runbook, findings, and the current adapter gaps: docs/tracking/onboarding-ux-takeover.md (T3-0). Mock
mechanics: the vendor-mock-development skill.
Agent tooling (Serena + Graphify MCP)
Runs on the host, not inside Docker — the code-graph MCP servers this repo wires in for AI
agent sessions (D104): Serena (LSP/Intelephense-backed symbol lookups)
and Graphify (tree-sitter module/dependency graph), both registered in the committed
.mcp.json alongside the existing Laravel Boost MCP server.
make setup-agents # new machine, one shot: installs uv + Serena + Graphify, registers the
# plugin marketplace, generates ide-helper stubs, builds the codemap, then
# runs agents-doctor — verifies binaries installed, plugin/marketplace
# registered, .mcp.json parses, and the graph is fresh. It does NOT verify
# that each MCP server actually responds to a query — that's the manual
# step in the kit's ROLLOUT-CHECKLIST.md ("confirm the 7 agents appear and
# Serena answers a symbol query")
make agents-doctor # diagnose: binaries present, plugin marketplace registered, graph fresh, .mcp.json parses
make codemap # rebuild the Graphify graph (graphify-out/, gitignored) — re-run if older than HEAD
make ide-helper # regenerate Laravel IDE-helper stubs — re-run after any composer change,
# or Serena/Intelephense goes facade-blind
Every target here is idempotent — safe and cheap to re-run on an already-configured machine.
The roster of agents these servers back (7 tiers, pinned models) comes from the
stables-agent-kit plugin, referenced from .claude/settings.json; see the agent-workflow
skill for dispatch rules and tool routing.
Xdebug
Installed in docker/Dockerfile.dev but off by default (XDEBUG_MODE=off). Enable
per-session with make up-xdebug (or XDEBUG_MODE=debug,develop docker compose up). Each
container dials back to a dedicated IDE port — configure three debug configurations:
| Container | IDE listen port |
|---|---|
app | 9003 |
worker | 9004 |
scheduler | 9005 |
Concurrency & environment caveats
- Shared test database. All sessions/worktrees share
stables_testing; concurrent full-suite runs contend and produce failures that look like isolation bugs. Stagger full-suite runs across parallel sessions. (make test-paralleladditionally provisions per-workerstables_testing_test_{token}databases — within one run, workers are isolated.) - A stuck or interrupted suite doesn't clean itself up on its own.
make test/test-parallelrefuse to start a second run on top of one already running in the app container;make test-killclears a wedged or zombied one (idempotent — safe even when nothing is running). See Testing & the gates and D106 for the failure modes this guards against. - Root-owned files. Files created inside a container are root-owned on the host —
chownthem before editing. - Secondary git worktrees.
makemay not resolve paths from a worktree —docker execinto the running app container directly (the repo is mounted at/app). - Production Docker files are out of bounds for local work:
docker/production/is used exclusively by the production CI/CD build.