Skip to main content

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).

ServiceHost URL / portPurpose
apphttp://localhost:18000Laravel web server (php artisan serve)
mailpithttp://localhost:18025 (UI), 11025 (SMTP)Mail catcher
postgreslocalhost:15432PostgreSQL 18
valkeylocalhost:16379Valkey (Redis-compatible) cache/queue
miniohttp://localhost:19001 (console), 19000 (S3 API)S3-compatible object storage
vendor-mockhttp://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 s3 disk (AWS_ENDPOINT=http://minio:9000, bucket stables-local — see .env.example). Helpers: make shell-s3 (an mc shell with the local alias 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

GroupTargets
Stacksetup up up-fg up-xdebug down down-clean restart(-app/-worker/-scheduler) ps status build rebuild
Logslogs logs-app logs-worker logs-scheduler logs-db logs-cache logs-s3 pail
Shellsshell shell-worker shell-scheduler shell-db (psql) shell-cache (valkey-cli) shell-s3 (mc)
Artisan/Composerartisan ARGS="..." tinker routes composer ARGS="..." composer-install
DB & migrationsmigrate 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 & gatestest-parallel test test-filter FILTER=... test-coverage test-kill lint lint-check stan — see Testing & the gates
Queue & cachequeue-failed queue-retry queue-flush cache-clear config-clear
API artifactsopenapi-export abilities-export
Audit opsaudit-verify-chain audit-export-worm audit-verify-worm
Vendor mockvendor-mock-generate vendor-mock-up test-vendor-mock
Live vendorsumsub-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:

ContainerIDE listen port
app9003
worker9004
scheduler9005

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-parallel additionally provisions per-worker stables_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-parallel refuse to start a second run on top of one already running in the app container; make test-kill clears 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 — chown them before editing.
  • Secondary git worktrees. make may not resolve paths from a worktree — docker exec into 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.

← Engineering wiki index