Adapted from the repo's docs/runbook.md — Part of Internals: the platform's design set, published as content on the platform it describes.
Operational runbook for bringing the stack up locally and driving it day to day. The
source of truth for the topology is ../infra/README.md; this
document is the short path from a clone to a running, verifiable system. When the two
disagree, infra/README.md wins.
Everything runs inside DDEV — the host needs only Docker + DDEV, no local PHP or
Node.js. If a task can't be driven through ddev, that's a gap to close in infra/, not
a dependency to install on your machine.
TL;DR (already bootstrapped)
ddev start # brings up web (Laravel), db (Postgres 16), redis, frontend (Next.js)
ddev describe # URLs + service health
- Frontend (reader app): https://publishing-platform.ddev.site
- API (base path
/v1): https://api.publishing-platform.ddev.site/v1 - Liveness: https://api.publishing-platform.ddev.site/v1/health
Stop with ddev stop; full reset in Reset.
Prerequisites (host, one-time)
- DDEV ≥ 1.23.5 and a Docker provider.
- Claude Code with Agent Teams enabled (preconfigured in
.claude/settings.json).
No host Node.js or PHP is required or assumed.
First-time setup
A. Existing project, fresh clone
ddev start # creates containers; provisions Postgres + Redis
ddev composer install # backend PHP deps (in the web container)
ddev frontend npm install # frontend deps (in the Node 22 container)
ddev artisan migrate # apply the schema
# optional: ddev artisan db:seed
ddev contract-lint # confirm the contract is green
Then run the smoke test.
B. Brand-new project from the template
Only when spinning a new repo from this template (see ../README.md):
cp -r <this-template> my-project && cd my-project && git init
grep -rl '<PROJECT_NAME>' . --exclude-dir=.git | xargs sed -i 's/<PROJECT_NAME>/my-project/g'
infra/ddev/bootstrap.sh my-project # generates .ddev/ (config, add-ons, services, commands)
ddev start
# install the Laravel + Next.js skeletons per infra/README.md, then:
ddev contract-lint
What comes up
ddev start brings up the whole system (topology detail in
../infra/README.md). It is a host-split topology —
two hostnames on the shared 80/443 router resolve to two containers, https-only with an
automatic http→https redirect:
| Service | Runtime | Reached at |
|---|---|---|
web |
PHP 8.5 (nginx-fpm), Composer, Node 22 | https://api.publishing-platform.ddev.site (API under /v1) |
frontend |
Node 22 running npm run dev |
https://publishing-platform.ddev.site (also :3000/:3001) |
db |
PostgreSQL 16 | ddev psql (db:5432 inside) |
redis |
Redis | redis:6379 (cache, queues, sessions) |
A Laravel scheduler daemon (schedule:work) also runs inside web and fires the
~1-minute reindex tick.
Smoke test
# API liveness (expect HTTP 200, application/problem+json only on failure)
curl -sS https://api.publishing-platform.ddev.site/v1/health | head
# Public read endpoints (contract/openapi.yaml, currently v0.8.1) — all under /v1:
# GET /health /site /articles /articles/{path} /sections /sections/{path}
# GET /tags /tags/{tag} /authors /authors/{author} /search?q=... /assets/{path}
curl -sS 'https://api.publishing-platform.ddev.site/v1/search?q=hello' | head
# Frontend renders:
curl -sSI https://publishing-platform.ddev.site | head -1 # expect 200
The read boundary is unauthenticated and read-only (ADR-0006); paths/verbs/payloads
conform to contract/openapi.yaml.
Everyday commands
| Task | Command |
|---|---|
| Start / restart / stop | ddev start · ddev restart · ddev stop |
| Backend (Composer/Artisan) | ddev composer <args> · ddev artisan <args> |
| Run a migration | ddev artisan migrate |
| Frontend | ddev frontend npm <args> (e.g. npm run dev is already the service) |
| Postgres shell | ddev psql |
| Redis shell | ddev redis-cli |
| Lint the contract | ddev contract-lint |
| Tail logs | ddev logs -s web · ddev logs -s frontend |
Publishing & updating articles
The filesystem is the CMS (ADR-0005): content lives in backend/storage/app/content/ —
each directory is a section (companion metadata.json), each *.md file an
article, path = URL. What you actually have to do after writing or editing:
1. Write the file — front-matter reference
---
title: "…" # becomes the page's ONLY h1
description: "…" # lists, meta tags, feed item summary (optional;
# the feed also carries the full rendered body)
author: "Bogdan Florian" # free label; slug derived server-side
published_at: 2026-07-06T22:00:00Z # UTC gate — future instant = hidden until then
updated_at: 2026-07-06T22:00:00Z # "Updated" shows only when a DIFFERENT calendar day
tags: [x, y]
draft: false # true = invisible everywhere
unlisted: false # true = reachable by URL + site.nav, but excluded from
--- # listings/tags/authors/search/feed/prev-next (ADR-0015)
- Scheduling is automatic: a future
published_at(orscheduled_at) keeps the article hidden until that UTC instant — no action needed at go-live. - The body may start with its own
# Heading— the renderer demotes body h1→h2. - Images: put files in an
assets/subdir next to the article and reference them relatively (cover: "assets/x.png",!…). Served via/v1/assets; allowlist png/jpg/jpeg/gif/webp/avif (no SVG, no.md/.json). ```mermaidfences render client-side (ADR-0012) — just write them.
2. Indexing — nothing to do (usually)
The scheduler reindexes every minute; body edits re-render automatically (the render cache keys on content hash + renderer version). To force it immediately:
ddev artisan content:reindex
3. Seeing it in the browser — nothing to do (Track 4d)
Save the file and reload: the page is fresh within ~90 s, no restarts. The loop
(ADR-0016): reindex tick (≤ 1 min) → content.* events → content:relay-events →
signed webhook POST /api/revalidate on the frontend → revalidateTag → the next
request re-renders. This covers articles, sections, tags, authors, search, the feed,
the sitemap, AND the site-identity block — typically < 60 s end to end. To skip the
tick wait:
ddev artisan content:reindex # reindex + relay now; fresh on the next reload
Both tiers need the same REVALIDATE_SECRET set (frontend/.env.local +
backend/.env) — with it missing or mismatched the webhook is rejected (401/503) and
freshness falls back to the ISR safety-net windows: ≤ 5 min for pages, ≤ 1 h
for feed.xml/sitemap.xml.
Troubleshooting fallback (rarely needed now)
If a change refuses to appear (webhook misconfigured, relay down), the old manual bust still works:
docker restart ddev-publishing-platform-frontend # boots warm in seconds
If styling/assets still look stale after a restart (wedged Turbopack cache — happens if the container previously died mid-compile):
docker exec ddev-publishing-platform-frontend rm -rf /var/www/html/frontend/.next
docker restart ddev-publishing-platform-frontend
Site identity & nav
Masthead name, home thesis/tagline, footer memo/copyright, and the top navigation are all
the site block of the root backend/storage/app/content/metadata.json (ADR-0014/15).
The API serves an edit on the next request (no reindex needed); the frontend follows
via the same revalidation webhook (the site cache tag) — worst case the ISR safety-net
windows above.
Quick sanity check
curl -s https://api.publishing-platform.ddev.site/v1/articles/<path> | head
Full payload = indexed and published. A problem+json 404 = still gated (future date,
draft) or not yet reindexed. An unlisted page is absent from /v1/articles but 200 by
path — that's the feature, not a bug.
Verify (Definition of Done)
Run the owning tier's verify before calling work done (also in CLAUDE.md):
| Scope | Command | Covers |
|---|---|---|
| Contract | ddev contract-lint |
Spectral ruleset (in-container) |
| Backend | ddev composer verify |
Pint check + PHPStan + artisan test |
| Frontend | ddev frontend npm run verify |
lint + typecheck + tests + build |
| Platform | ddev restart boots green; services respond |
topology |
Host-split routing gotcha
The DDEV-generated Traefik dynamic config is taken over by the project — the live
file .ddev/traefik/config/publishing-platform.yaml is git-ignored and DDEV will not
regenerate routing when hostnames/ports change. The editable source of truth is
../infra/ddev/traefik/publishing-platform.yaml;
a post-start hook re-installs it on every start.
Change routing by editing the
infra/copy, thenddev restart— never hand-edit the git-ignored live copy.
If HMR breaks, confirm the frontend keeps the bare-host origin in next.config
allowedDevOrigins and its API base URL points at the api. host.
LAN access (optional)
To reach the stack from other machines on the trusted internal network, two binds are
required (bind_all_interfaces in-repo + router_bind_all_interfaces global, then
recreate the router) and external machines must map both hostnames to this host's LAN IP.
Full procedure — including the one-time global step and the docker rm -f ddev-router && ddev start router re-bind — is in
../infra/README.md. LAN
devices must also trust the mkcert CA to accept the certificate.
Reset / teardown
ddev stop # stop containers (data preserved)
ddev restart # recreate + re-run post-start hooks (Traefik re-install)
ddev delete -O # DESTRUCTIVE: remove project + database volume (keeps files)
After a fresh ddev delete/clone, re-run First-time setup.
Troubleshooting
| Symptom | Check |
|---|---|
ddev start fails on frontend port 3000 |
Don't add a raw ports: mapping to the frontend service — the router already binds :3000 (infra/README.md). |
| API 404s on a path you expect | Route ordering in backend/routes/api.php is load-bearing; the path must exist in contract/openapi.yaml first. |
| Contract lint fails | ddev contract-lint prints the Spectral rule + location; the contract is the source of truth, fix there and let consumers follow. |
| HMR / websocket dead | Traefik forwards Connection: Upgrade; verify allowedDevOrigins and that you edited the infra/ Traefik source, then ddev restart. |
Ports show 127.0.0.1 when you want LAN |
Trust ss -ltn, not ddev describe's cosmetic display; see LAN section. |