Adapted from the repo's docs/how-it-works.md — Part of Internals: the platform's design set, published as content on the platform it describes.
Engineer-to-engineer walkthrough of how an article travels from a file on disk to a rendered page in the reader. Grounded in the shipped code and the design set; it links to the ADRs rather than re-arguing them. Read
architecture.mdandmaster-blueprint.mdfirst for the tier model and the product design this implements. Reflects contract v0.8.1 and ADRs 0005–0017.
Thesis
The filesystem is the CMS (ADR-0005).
Content lives as a recursively-scanned tree of sections (a directory + optional
metadata.json) and articles (a *.md file: YAML front-matter + Markdown body), rooted
at CONTENT_ROOT (storage/app/content). Path = identity = URL: an article's slug
path relative to the root (minus .md) is its canonical id and its front-end URL. There is
no admin panel — authoring is out-of-band, via an editor and git
(ADR-0006). PostgreSQL holds a
rebuildable derived index (one row per article/section: parsed metadata, resolved
publication state, go_live_at, tags, author slugs, a content_hash, and an FTS vector) —
never the source of truth, always reconstructable from disk. Redis does two jobs: it
caches rendered+sanitised HTML keyed by renderer-version + content-hash, and it
carries the content event streams. The backend exposes a read-only, unauthenticated
REST API under /v1 on a dedicated api. host
(ADR-0010); the Next.js reader
lives on the bare host and consumes the API only through the client generated from
contract/openapi.yaml — the contract is the single coupling point between the tiers.
The two stores are strictly derived, in one direction:
| Store | Role | Rebuild |
|---|---|---|
Filesystem (CONTENT_ROOT) |
Source of truth for content — the only place a human edits | — |
| PostgreSQL 16 | Derived query index (list/filter/paginate/FTS; subtree via section paths) | artisan migrate:fresh + artisan content:reindex |
| Redis | Render cache (rendered-content:v{VERSION}:{hash}, 7-day TTL) + event streams |
Warmed on next read / next reindex |
Architecture
flowchart TB
subgraph authoring["Authoring — out of band"]
editor["Author's editor / git"]
tree["Content tree<br/>storage/app/content/**<br/>metadata.json · *.md · images"]
editor -->|"write files"| tree
end
subgraph backend["backend/ — Laravel API (api. host, base /v1)"]
sched["Scheduler<br/>schedule:run every minute"]
reindexer["Reindexer<br/>content:reindex"]
relay["Event relay<br/>content:relay-events"]
http["Http controllers<br/>Domain actions → ContentIndex port"]
pg[("PostgreSQL<br/>derived index")]
rcache[("Redis<br/>render cache")]
streams[("Redis<br/>event streams")]
sched -->|"tick"| reindexer
sched -->|"tick (seconds later)"| relay
reindexer -->|"read + parse"| tree
reindexer -->|"upsert / prune"| pg
reindexer -->|"warm by content-hash"| rcache
reindexer -->|"emit events post-commit"| streams
relay -->|"read past cursor"| streams
http -->|"query PUBLISHED"| pg
http -->|"rendered HTML by hash"| rcache
http -->|"stream asset bytes"| tree
end
subgraph contract["contract/ — the tier boundary"]
spec["OpenAPI 3.1 · AsyncAPI 3.1<br/>v0.8.1"]
end
subgraph frontend["frontend/ — Next.js reader (bare host)"]
reval["POST /api/revalidate<br/>signed webhook receiver"]
pages["Server components — ISR<br/>tagged fetches, generated client"]
feed["feed.xml · sitemap.xml<br/>derived routes"]
end
browser["Reader's browser"]
relay -->|"signed webhook (HMAC-SHA256)"| reval
reval -->|"revalidateTag content / site"| pages
browser -->|"HTTPS page request"| pages
pages -->|"generated client → GET /v1/*"| http
feed -->|"listArticles + getArticle + getSiteMeta"| http
browser -.->|"img src → GET /v1/assets/*"| http
http -.->|"conforms to"| spec
pages -.->|"generated from"| spec
The write path flows top-down: editor → content tree → scheduler → Reindexer → Postgres
index + Redis render cache + Redis event streams → relay → signed webhook → Next
/api/revalidate → tag bust. The read path flows bottom-up: browser → Next (ISR, tagged
fetches) → /v1 API → index/cache → filesystem assets. frontend/ and backend/ never
touch each other's code — every solid up-arrow across the middle is mediated by contract/.
The API lives on the api. host; the reader on the bare host; SSR/build fetches actually go
container-internal (http://web/v1) because the public cert is untrusted inside the frontend
container (ADR-0010).
Write → live: the end-to-end sequence
sequenceDiagram
autonumber
actor Author
participant FS as Content tree
participant Sched as Scheduler
participant RI as Reindexer
participant PG as Postgres index
participant RC as Redis render cache
participant ES as Redis event streams
participant Relay as Event relay
participant Next as Next.js frontend
participant HTTP as API /v1
actor Reader
Author->>FS: save article.md (front-matter + Markdown body)
Note over Sched,RI: schedule:run every minute → content:reindex
Sched->>RI: run reindex tick
RI->>FS: enumerate tree + parse front-matter
RI->>RI: PublicationPolicy gates on go-live instant
RI->>RC: render + sanitise (CommonMark, h1→h2), cache by content-hash
RI->>PG: upsert changed / prune deleted (hash-guarded, one tx)
RI->>ES: emit article-published + reindexed (after commit)
Note over Sched,Relay: same minute → content:relay-events
Sched->>Relay: run relay
Relay->>ES: read entries beyond the persisted cursor
Relay->>Relay: change-detection rule (deltas > 0 or new content-version?)
alt run changed content
Relay->>Next: POST /api/revalidate (HMAC-signed raw body)
Next->>Next: verify signature, revalidateTag content / site (expire 0)
Next-->>Relay: 200 → advance cursor
else no-op run
Relay->>Relay: advance cursor, send nothing
end
Reader->>Next: GET the article page
Next->>HTTP: getArticle via generated client (internal /v1)
HTTP->>PG: query PUBLISHED where go_live <= now
HTTP->>RC: fetch rendered HTML by content-hash
HTTP-->>Next: Article JSON (sanitised HTML body)
Next-->>Reader: server-rendered page
Step by step, against the real classes:
- Author saves a file. No process is notified; the tree is just newer than the index.
- Scheduler tick (
routes/console.php):Schedule::command('content:reindex') ->everyMinute()->withoutOverlapping(), thencontent:relay-eventsimmediately after, so oneschedule:rundoes both within seconds. - Reindex (
App\Infrastructure\Index\Reindexer): enumerate viaContentSource, parse front-matter, applyPublicationPolicy::state(frontMatter, Clock)to gate on the go-live instant, render + sanitise through the CommonMark pipeline (below) memoised by content hash, then upsert changed rows and prune deleted ones inside a DB transaction. It is idempotent and hash-guarded: a row is rewritten only when itscontent_hashchanged or its publication state changed (aSCHEDULEDarticle crossing its go-live instant — clock-driven, no hash change). Unchanged content writes nothing. - Events, post-commit (
RedisEventPublisher→ Redis streams):content.article-publishedfor each article that became public, andcontent.reindexedonce per run (carrying thearticlesIndexed/sectionsIndexedtotals, the four per-run delta counters,skipped,durationMs, andcontentVersion). Shapes live incontract/asyncapi.yaml. - Relay (
App\Domain\Content\Action\RelayContentEvents, behindcontent:relay-events): reads both channels from a persisted per-channel cursor, applies the change-detection rule (anyarticle-published, or areindexedwhose deltas sum above zero, or a changedcontentVersion), and only on real change POSTs one aggregatedcontentChangedwebhook to the frontend, HMAC-SHA256-signed over the raw body. No-op runs send nothing. The cursor advances past a changed batch only after a 200 — at-least-once delivery; the receiver is idempotent by contract. Delivery failure is logged, never fatal, and never delays reindexing (ADR-0016). - Revalidate (
frontend/src/app/api/revalidate/route.ts): recompute the HMAC over the raw bytes, compare in constant time, reject missing/mismatching with 401 and no invalidation; on successrevalidateTag(tag, { expire: 0 })for the payload's tags, constrained to thecontent/siteallowlist. The next request re-renders fresh. - A visitor reads. A Next server component fetches through the generated client
(
frontend/src/lib/api/client.ts) with{ next: { revalidate, tags } }; on a tag-fresh cache it renders immediately, otherwise it hits/v1, which serves from the Postgres index + Redis render cache and returns the sanitisedArticleJSON.
Measured freshness (ADR-0016 acceptance): a publish is visible in ~21 s, a prune in ~51 s, worst case within ~90 s — the event loop collapses the ISR window to roughly one scheduler tick plus one render. ISR is the safety net, not the primary mechanism: content fetches default to a 300 s revalidate window (3600 s for feed/sitemap), so even if a webhook is missed, pages self-heal on the next window. Correctness never depends on the event loop — read-time gating already makes every query correct (below).
How the pieces work
Rendering pipeline (ADR-0007 + one-h1 invariant)
App\Infrastructure\Rendering\MarkdownRenderer runs league/commonmark → the shared
TrustedHtmlSanitizer allowlist (the XSS boundary; scripts, iframes, on* handlers,
javascript:/data: URLs are stripped) → stable heading anchor ids → a tableOfContents.
Any # h1 authored in the body is demoted to <h2> on the parsed AST (a
DocumentParsedEvent listener) so the front-matter title is the page's only h1
(ADR-0015). The whole pipeline is a pure
function of the input string, so its output is memoised in Redis by content hash; a renderer
behaviour change bumps MarkdownRenderer::VERSION (currently 2), which changes the cache
key so old HTML misses cleanly instead of going stale.
Publication gating (ADR-0008)
Visibility is a pure function of front-matter and the current time:
go-live = scheduled_at ?? published_at; DRAFT if draft:true or no go-live instant,
SCHEDULED if go-live is in the future, PUBLISHED once it passes. Read-time gating is
the source of truth — every list/fetch query filters state = PUBLISHED AND go_live_at <= now(), so the API is correct even if the scheduler never runs. Not-yet-public and unknown
paths both return 404, never 403 — existence is never disclosed
(ADR-0006).
Search (Track 4a — Postgres FTS)
GET /v1/search?q= runs full-text search over published articles via a weighted tsvector
(WeightedSearchVector, GIN-indexed) in PostgresContentIndex; SearchArticles returns a
paginated SearchResultList, most-relevant-first (ordering conveys relevance — no numeric
score), with an optional sanitised <mark>-highlighted snippet. q is trimmed and bounded
2–128 chars (400 otherwise); a valid query with no hits is a 200 empty page.
Assets (ADR-0011)
GET /v1/assets/{path} streams cover and in-body images from the content root, originals
as-is, behind a fail-closed media-type allowlist (png/jpg/jpeg/gif/webp/avif); anything
else — including the Markdown/JSON source — returns an identical 404, so the endpoint
can't be used to read unpublished source. Responses carry a strong ETag + long-lived
immutable Cache-Control and honour If-None-Match (304). Cover URLs and in-body img
srcs are rewritten to absolute asset URLs (InBodyAssetRewriter); the asset route has its
own 300 rpm limiter, exempt from the article limiter.
(ADR-0012: Mermaid fences render
client-side in the browser, and degrade to a visible code block anywhere the island can't
run — e.g. feed readers.)
Authors (ADR-0013)
author front-matter is a free display label, not an account. The server derives one
canonical kebab-case slug per label and publishes it on every byline (AuthorRef), in
the /v1/authors catalogue, and as the ?author= / /authors/{author} filter key — so a
byline's slug always equals its catalogue item's slug. Clients never guess slugs.
Site identity & navigation (ADR-0014 / ADR-0015)
GET /v1/site resolves the masthead title, tagline, homepage thesis, footer memo/copyright,
and curated nav from the optional site block of the root metadata.json — the
file-first replacement for env-var branding and hardcoded chrome. Resolution falls back to
the root section's title/description for identity fields; footer/nav fields have no
fallback. nav entries (NavItem) point at content paths — typically unlisted pages.
Unlisted pages (ADR-0015)
An article with unlisted: true is excluded from every listing surface — /articles
and all its filters, tag/author catalogues and pages, search, prev/next chains, and derived
surfaces like the feed and sitemap — while remaining fetchable at GET /v1/articles/{path}.
This is how a curated nav page (e.g. the About page, a root-level /about-me) reaches the
header without appearing in any list.
Feeds (ADR-0017 — full-content RSS)
frontend/src/app/feed.xml/route.ts builds an RSS 2.0 feed entirely from published
operations (listArticles + getArticle + getSiteMeta) — a frontend-derived artifact,
no contract change. Each item carries full sanitised HTML in
content:encoded (CDATA, split on any literal ]]>), an escaped <description>, absolute
<link> and guid isPermaLink="true", RFC-822 pubDate, per-author dc:creator, per-tag
<category>; the channel adds an atom:link rel="self" and <lastBuildDate>. A failed
per-item body fetch degrades that one item to summary-only rather than failing the feed.
Rendering ~20 full items fans out to ~20 getArticle calls, which is why ADR-0017 also
raised the api limiter 60 → 120 rpm per IP (the assets limiter stays 300 rpm).
The /shell terminal
bflo.sh/shell is a client-island browser terminal — a purely frontend feature layered on
the public read-only API; it introduces no new backend or contract surface.
Verify gates (Definition of Done, per tier)
| Scope | Command |
|---|---|
| Contract | ddev contract-lint (Spectral in-container) |
| Backend | ddev composer verify (Pint + PHPStan + artisan test) |
| Frontend | ddev frontend npm run verify (lint + typecheck + tests + build) |
| Platform | ddev restart boots green; /health content check passes |
Properties that hold (the invariants)
- One
<h1>per rendered page. The front-mattertitleis the sole h1; body h1s are demoted to h2 before render/sanitise (ADR-0015). - Unlisted means hidden-but-reachable. Absent from every listing/derived surface, still retrievable by path (ADR-0015).
- No forbidden tier edges.
frontend → contract ← backend; inside backendHttp → Domain → Data (ports). Domain/Data never import Http; controllers never touch Eloquent directly (architecture.md). - The contract is the only coupling point. The frontend consumes only the generated client; neither tier reads the other's source, types, or database.
- The index is always rebuildable. Filesystem is the sole source of truth; Postgres +
Redis are derived and reconstructable with
migrate:fresh+content:reindex(ADR-0005). - Read-time gating is authoritative. Every query filters
PUBLISHED AND go_live_at <= now(); the scheduler/relay are optimisation + notification, never correctness (ADR-0008). - Unpublished existence is never disclosed. SCHEDULED/DRAFT/unknown paths, and non-servable assets, all return an identical 404 (ADR-0006, ADR-0011).
- Rendering is a trusted, sanitised, deterministic function of the input string, safe to cache by content hash; the sanitiser allowlist is the XSS boundary (ADR-0007).
- Webhook delivery is at-least-once and idempotent. Signed over raw bytes, verified in constant time; the cursor advances only after acknowledgement; no-op runs notify nothing (ADR-0016).
Sources
- Design set:
architecture.md,master-blueprint.md(content model §3, stores §4, backend §5, contract §6, sequencing §11). - ADRs: 0005, 0006, 0007, 0008, 0010, 0011, 0012, 0013, 0014, 0015, 0016, 0017.
- Contract:
../contract/openapi.yaml,../contract/asyncapi.yaml(both v0.8.1). - Code:
backend/app/Domain/Content/**,backend/app/Infrastructure/{Index,Rendering,Cache,Events,Content}/**,backend/routes/{api.php,console.php},frontend/src/lib/api/client.ts,frontend/src/app/{feed.xml,api/revalidate}/route.ts.