Skip to content

Documentation Guide

In brief

  • Documentation is one of the three places every change lands, and a document that contradicts the code is a defect.
  • Every topic has exactly one owning file. Other files link to it rather than restate it.
  • The audience decides the directory and the voice, and every file declares itself in its own front matter.
  • Every file opens with a summary, and the doc smells below are how a review names what is wrong with one.
  • Write for one pass: the result first, and no term the reader is assumed to already know.

The rules and expectations for documentation in lara-spec-first. Yes, this is documentation about documentation, and it is here for the same reason every other subject has one home: the rules apply to contributors as much as to agents, so they should not be buried inside a file addressed to agents.

Documentation is one of the three places every change must land, alongside the code and the tests.

Documentation must follow the code

Documentation is part of the definition of done, not a follow-up task. This repository documents a package that does not fully exist yet, so drift between doc and code is the expected failure mode here. Treat a contradiction between the two as a defect, not a nitpick.

  • Ship the docs in the same change as the code. A change to behavior, commands, config keys, or the public API is incomplete until the affected documents are updated alongside it.
  • In review, validate the change against what the documentation claims. Read the relevant files from the inventory, then check the diff against them. Report every contradiction, naming the file and the line the code disagrees with. This is a required review step, not an optional one.
  • Work out which side is wrong. If the code is right, the doc is stale, so update it. If the doc states the intended design and the code departs from it (for example, making PHP authoritative over the spec), that is a design defect: raise it rather than quietly rewriting the doc to match the code.
  • Keep stack.md honest. When a choice moves from Undecided or Planned to Decided, that update belongs in the same change that installs it.
  • A missing doc is a finding. A new Artisan command, config option, or extension point that ships undocumented is an incomplete change.
  • American spelling. honor, behavior, normalize, organize, serialization. The repository was swept once and is consistent; keep it that way. The exception is composer analyse, which is a command name, not prose.

One topic, one file

Documentation follows separation of concerns, exactly like code. Every topic has one authoritative home, and only one. When another document needs that topic, it links to the owning file instead of restating it, with an anchor when it needs to point at a specific part:

markdown
See [the code review priorities](../../AGENTS.md#code-review). Full version constraints live in
[`stack.md`](../project/stack.md#supported-versions-at-a-glance).

The rules:

  • Duplicate the pointer, never the reasoning. A one-line mention that orients the reader is fine, since the README exists to sell the project and route people onward. What must never appear twice is the detail: the justification, the version numbers, the trade-off, the commands.
  • Two explanations of one subject will diverge. Not might, will. And when they do, nothing tells a reader which one is stale. That is the entire cost this rule avoids.
  • A new topic that fits no existing file gets its own file, in the directory its audience owns, plus an entry in the inventory. Do not append an unrelated section to whichever document happens to be open.
  • Prefer moving over copying. If a section has outgrown the file it sits in, relocate it and leave a link behind. Never leave both copies.

Signs the rule is being broken: the same decision justified in two places; a section that re-explains something the reader was already sent elsewhere to read; a file whose title no longer covers everything inside it.

A large subject becomes a directory

When one topic outgrows one file, it becomes a directory holding an index.md and the files it splits into. Not several files side by side in docs/guide/: that turns one subject into several sidebar entries with nothing saying they belong together, which is a worse answer to "this file is too long" than the length was a problem.

The index is not a table of contents. It carries what the other files depend on, plus the part of the subject nothing else claims, and it links onward from the body where each question arises. An index that lists its own siblings duplicates two things that already exist: each file's covers, and the inventory, which deliberately carries no descriptions for exactly that reason.

Three things follow, and none of them needs configuring:

  • The site publishes the directory as one collapsible entry whose clickable parent is the index and whose children are its siblings, alphabetically. .vitepress/config.mts names it in a sequence by its directory name, like any other page.
  • The directory answers on its own URL, so /docs/guide/code-generation keeps resolving after the split and no external link to the subject breaks.
  • Every link written as Markdown does have to move, because the file did. just docs-check-anchors is what makes a missed one a red check rather than a fragment nobody notices.

The reader decides the directory

Every document is written for one reader, and the directory it sits in is that answer. Placing a new document therefore means naming its audience first. A file with two readers is not one file with a wide audience. It is two files that have not been split yet.

This is a forcing function, not a filing convention, and it exists because the alternative was tried. Most of these documents used to declare Users, contributors and agents, which is indistinguishable from declaring nobody: a page that teaches a consumer how a build works and rules a reviewer must enforce serves neither reader well, and nothing in the repository objected. The tree objects.

audienceLives inAnswers
Usersdocs/guide/How to use the package, and what it promises.
Users and contributorsdocs/project/Where the project is going, and what it is built on.
Contributorsdocs/contributing/Why it is built this way, and how to work on it.
AI coding agentsAGENTS.mdHow an agent works in this repository.

The rules:

  • The vocabulary is closed. Those four values, spelled exactly like that. Needing a fifth means needing a new section of the tree, which is a decision to raise rather than one to make while moving a file.
  • agents is never an audience alongside another. An agent reads whatever its task touches, so naming it next to a human reader adds no information, which is precisely how Users, contributors and agents came to mean nothing. AGENTS.md is the one document whose reader is an agent, and it stays at the repository root, where an agent looks first.
  • Two root files are conventions rather than exceptions. README.md and CONTRIBUTING.md stay at the root because GitHub and every contributor expect them there. CONTRIBUTING.md is a Contributors document that happens to live outside docs/contributing/.
  • Audience decides the section, never the visibility. Every directory above is published on the documentation site, see the site row in stack.md. Splitting by reader is not splitting by secrecy: a contributor page is as worth finding, by a person or by a crawler, as a user page. What the split buys is that the reader lands among pages written for them.
  • Moving a document is a rename with links to fix. Nothing else in this file changes: the topic it owns, its covers claim and its tags travel with it.

The audience decides the voice too

A document sounds like the reader it declares. The four voices below are not four styles chosen for variety: each one follows from what its reader arrived to do, and a page that reads like the wrong one is usually a page filed in the wrong directory.

audienceSounds like
UsersSecond person. The reader is doing something, so name the trap before they reach it, and show code wherever there is code to show.
Users and contributorsA statement of where things stand. A row is a fact with a status on it, never a pitch, and a date beats an adjective.
ContributorsA rule with the reasoning under it. This reader is deciding rather than following, so an argument they can disagree with is worth more than an instruction they cannot.
AI coding agentsImperative and checkable. An agent does not need persuading, so a sentence that argues is a sentence that could have been a rule.

The shape follows the reader the same way, and this set has two of them. A page that decides opens with its summary, states the decision, puts the reasoning under it, and names the alternative it turned down along with why. A page that shows a manipulation opens with what the reader came to do, and brings the reasoning in where it is needed to make the next step make sense. Both put a trap at the point where it bites rather than in a warning at the end, and both close on what the document does not cover.

Three rules cross all four, because they are what this set is actually for:

  • Say why, not only how. A decision without its reasoning is a rule the next reader can only obey or break, never argue with. This is what the decision tunnel smell does not complain about and it is the habit worth protecting hardest.
  • Where several paths exist, name the one to take. Listing them level hands back a choice this repository has already made, and an "it depends" with no criterion under it is not neutrality: it is a decision nobody took. Where the answer does depend on something, name the something.
  • Name the trap before the reader reaches it. Where a thing is easy to get wrong, the page says so at the point where a reader would get it wrong, not in a caveat at the bottom. base_path() is not workbench/; a formatter and the build will rewrite each other forever; a POST on an addressed resource is an action rather than a creation. Each of those is a paragraph that exists because somebody would otherwise have spent an afternoon on it.

What the voice is never allowed to add is enthusiasm. A warmer register makes the temptation stronger, not weaker, which is why the adjective rule sits where it does. Second person and a named trap are the whole of the warmth this set wants.

Front matter metadata

The rule applies to a document's own description too. Every Markdown file declares what it is in its own YAML front matter, and no index elsewhere restates it. The same five fields in every file:

Indentation and bracket spacing come from the formatter. The example below is stack.md's actual front matter. If the two ever differ, this example is the one that is wrong.

yaml
---
title: Technical Stack
audience: Users and contributors
covers: >
    Every technology choice with its status and reasoning, the supported PHP and Laravel matrix, and the rules for
    changing a stack decision.
read_before: Touching dependencies, version constraints, or CI configuration.
tags: [stack, dependencies, versions, php, laravel, ci, decisions]
---
FieldRequiredPurpose
titleyesHuman-readable name. May differ from the filename.
audienceyesWho the document is written for. One of four values, which also fixes the directory.
coversyesThe subjects this file owns. If two files claim the same subject, one of them is wrong.
read_beforenoThe action that should trigger reading this file. This is what makes the set navigable to an agent that has never seen the repository.
tagsyesSubject keywords, for finding relevant documents without opening each one.

Rules:

  • Front matter is authoritative. The inventory lists files only, and it deliberately carries no descriptions, because that is what covers is for.
  • A new Markdown document without front matter is an incomplete change, with one exception below.
  • covers is a claim of ownership. Before adding a subject to a file, check that no other file already claims it. Overlapping covers fields are the earliest warning that duplication is starting.

Tags

Tags exist so that a reader, human or agent, can find every document touching a subject with one grep, without opening files or guessing at filenames. That only works if the vocabulary stays small and consistent:

bash
grep -rl 'tags:.*versions' --include='*.md' .
  • Reuse before inventing. Check the vocabulary below first. Two tags meaning the same thing are worse than one imperfect tag, because each one hides half the results.
  • Lowercase, kebab-case, singular. code-review, not Code Reviews.
  • Three to seven tags per document. Fewer and it will not be found; more and every tag matches everything, which is the same as no tags at all.
  • Tag the subject, not the audience. audience is already its own field.
  • Adding a new tag means adding it to the vocabulary below, in the same change.

Tag vocabulary

TagSubject
agentsHow AI agents should work in this repository
ciContinuous integration and the build matrix
code-generationProducing PHP from the specification, and the rules that keep it safe to re-run
code-reviewReview priorities and how findings are reported
commandsThe Artisan commands this package ships, and how they are invoked
compatibilityWhat the package honors of a standard, and the promise attached to it
conventionsCommit, naming, and style conventions
contributingHow to contribute: setup, pull requests, conduct
decisionsChoices made, with their reasoning
dependenciesThird-party packages and version constraints
dockerThe containerised development environment
documentationHow documentation itself is written and organized
driversThe driver extension mechanism and the features built on it
laravelLaravel version support and framework integration
metadataFront matter and document metadata
migrationMoving an existing Code-First app to Spec-First
onboardingGetting a newcomer or a fresh agent productive
openapiThe OpenAPI specification and its parsing
paginationPaging a collection: how a specification declares one and how the package reads it
phpPHP version support and language constraints
planningRoadmap, phases, and sequencing
rate-limitingRate limits: how the specification declares one and how the package reads it
scopeWhat belongs in this package and what does not
securityAuthentication, authorization, and the boundary of what the spec can express
stackTechnology choices
testingTest levels, expectations, and the test suite
versionsSupported and required versions
workflowThe day-to-day process of making a change

The README carries no front matter, deliberately

This is a decision, not an oversight. Do not add front matter to README.md. Two reasons:

  • The metadata exists to route readers between documents, and the README is where routing starts. It is never a destination reached by consulting an index, so the fields carry no information there: read_before would read "anything else", audience would read "anyone", and covers would restate section headings the reader can already see.
  • It is the repository's landing page. GitHub renders front matter as a table at the top of a Markdown file, which would place a metadata block above the project title on the page whose only job is to explain the project.

Every other Markdown document in the repository takes the full set of fields.

Every document opens with a summary

Every Markdown document in the inventory opens with a summary, titled "In brief", directly under the # title and above the first section. It carries what the reader needs if they read nothing else.

It exists because of the failure this set is most exposed to. These documents argue: they record a decision and the reasoning that produced it, which is deliberate and is not changing. The cost is that the first thing a reader meets is an argument, and working out what the file actually claims takes five paragraphs. The summary pays that back at the top, for four lines.

A blockquote titled In brief, three to five bullets, one line each. This file's own summary is the example. If the two ever differ, the example is the one that is wrong:

markdown
> **In brief**
>
> - Documentation is one of the three places every change lands, and a document that contradicts the code is a defect.
> - Every topic has exactly one owning file. Other files link to it rather than restate it.
> - The audience decides the directory and the voice, and every file declares itself in its own front matter.
> - Every file opens with a summary, and the doc smells below are how a review names what is wrong with one.
> - Write for one pass: the result first, and no term the reader is assumed to already know.

The rules:

  • A blockquote, never a heading. The site builds each page's outline from its headings, so an ## In brief in every document adds one entry of pure noise per page. A blockquote also renders on GitHub, which is where AGENTS.md and CONTRIBUTING.md are actually read.
  • Three to five bullets, one line each. Under three, and the file probably should not be a file of its own. Over five, and it is a table of contents rather than a summary, which is a smell rather than a thorough one.
  • Written for somebody who has not read the page yet. Name who does the thing, the package, the build or you, and reach for the plain verb: reads, writes, refuses. "Both versions are read, and a documented subset is honored" has no actor in it and leans on a word this set coined, which makes the one paragraph written for a reader who has read nothing the one paragraph that needs the page first. The coined vocabulary belongs below, in the section that defines it.
  • Assertions, not subjects. "Nothing at runtime ever opens a specification" tells the reader something. "The relationship between the runtime and the specification" sends them into the body, which is what the summary was there to spare them.
  • Name what is not built yet. Much of this set describes behavior that does not exist, and the phase banner that says so is usually further down. A closing bullet separating what runs from what is designed is what stops a reader planning around a feature nobody has written.
  • README.md is exempt. The whole file is already a summary of the project, which is the first of the two reasons it carries no front matter either.

A summary is not a second covers

Two summaries at the top of one file is duplication, so the split has to be stated rather than felt:

  • covers is written for the reader deciding whether to open the file. It claims subjects: this is what this file owns. It is an index entry, and it is what makes an overlap between two files detectable.
  • The summary is written for the reader who has already opened it. It gives answers rather than subjects: this is what the file says about them.

The test: a bullet that could be pasted into covers unchanged is a badly written bullet. It named a topic where it owed a claim.

Write for one pass

A sentence the reader has to read twice has failed, however precise it turns out to be on the second reading. These documents are long and they argue, so the writing owes back what the arguing costs. One test says whether it did, and three rules do most of the work.

The Junior Dev Test

The re-read test needs a reader to run it as, or every author passes it on their own prose. Run it as an engineer who knows PHP and Laravel but has never seen this topic. That is the reader this set actually gets: a contributor on their first task, and an agent opening one page with no memory of the others.

Three questions, in order:

  • Would they get the purpose from the summary alone? If the point only lands in the third section, the summary is a table of contents.
  • Would they hit a term nobody defined? This is where you find the vocabulary you assumed, and the rule below is the fix.
  • Would they know a rule is a rule? State one as a present-tense fact: "the build refuses a path parameter Laravel cannot match", never "we felt it was probably better to refuse". Hedging reads as an open question, and an open question invites the reader to settle it themselves. Behavior that does not exist yet is the one exception, and it says so by naming its phase rather than by softening the verb.

Where an answer is no, the fix is structural rather than editorial: split the stacked sentence into bullets, define the term, or move the edge case into the section that owns the limits. Not into a collapsed block: a toggle keeps the page looking short while leaving the reader who needed that detail worse off than a link would, and this set already answers "secondary detail" with a file of its own.

The result comes first, the condition second

Put what happens at the front of the sentence and the circumstances behind it. A reader who stops at the comma still leaves with the answer.

  • Write: "Run spec:build to emit the routes."
  • Not: "When you need to emit routes for your application, you should run spec:build."

The same rule holds at paragraph scale: the claim goes in the first sentence, the reasoning underneath it. That is what makes a bolded lead sentence worth scanning rather than decoration.

An adjective must do work

Cut the word that asks the reader to believe something, and write the fact that produced it. robust, seamless, powerful, blazing, cutting-edge, comprehensive: each one is a conclusion offered before any evidence for it, and the replacement is never a milder adjective. It is the behavior the adjective was standing in for.

  • Write: "the build refuses a path parameter Laravel cannot match"
  • Not: "a robust build pipeline"

The same goes for a sentence that talks to the reader before it starts. "Certainly, here is how it works", "Let's dive in", "It's worth noting that": each is a line to get past before the first fact, and deleting it costs the reader nothing.

This set's own landing line is the example, and it is the real one: the README read "mock endpoints instantly with Faker, and seamlessly bridge legacy code" until this rule was written. Those were the two words in the whole set that sold rather than said, and both of them decorated work that is not built. What is left names what the package does.

Argued is not the same as enthusiastic. This repository documents its reasoning on purpose, and every guide in it argues for what it decided. What this rule cuts is borrowed enthusiasm, never the argument: a reason is a fact about why something is the way it is, an adjective of this kind is a mood.

The same ground is covered at more length by avoid-ai-writing, which is a useful checklist to read and not this repository's standard. What binds here is what is written on this page, for the reason every rule here carries its own reasoning: a standard kept in somebody else's repository can change without anybody noticing it changed.

A coined term is linked once

This set invents vocabulary, and it is right to: drift, Deferred, the marker, the source map, the invariant, an acknowledgement. Each one is precise, each one means nothing to a reader who has not met it yet, and this paragraph is the rule below applied to itself.

The first use of a coined term in a document is a link. Not a definition written out again: writing one in every document that uses the word is the same clause in ten files, which is the duplication smell with extra steps, and the copies are what go stale.

  • Write: "reporting drift is the doctor's job"
  • Not: "reporting drift is the doctor's job", leaving a reader who has not met the word

Two targets are correct, and the sentence decides. The glossary row is the default: one target per term, so a section that moves is one row to fix rather than ten links. Link the owning section directly when the sentence is already reaching for it, so "see the drift check" is better as itself than as a detour through a row. What is never correct is the third option, which is leaving the word bare.

The document that owns a term does not link to the glossary for it. It carries the definition, so pointing its own reader at a row that points back is a loop. Its first use links the section that defines it, or is that section.

Every term carries its own anchor in the glossary, the term itself with any leading article dropped: #drift, #invariant, #two-class-seam. So the link is guessable, and docs:check-anchors fails the build on one that is not. A row is a table cell rather than a heading, so the anchor is written as HTML.

The row is one clause and a link onward to the section that owns the concept, so the reader gets the short answer in one hop and the whole argument in two. That section stays the authority; the row is a pointer that happens to be enough most of the time.

First use in the document, not in the repository. A reader arrives on one page, never on the set.

First use, not every use. drift appears twenty-five times and acknowledgement thirty-three; linking each one would put a link in most sentences of doctor.md and teach nobody anything after the first. A later mention in the same document is prose.

Only the coined sense. Most occurrences of these words are ordinary English and must stay unlinked: drift is a doctor finding, but "the table drifts fastest" is a verb; Deferred is a support level, but "Deferred deliberately" is this set's own marker for an undecided item. Linking the ordinary sense is worse than linking nothing, because it makes the coined one invisible.

A term with no glossary row is a term nothing owns. Writing the row is what tells you: if there is no section to point at, that section is the thing to write, and the row comes after it.

The example comes early, never last

On a page written for Users, the example arrives in the first screen of its section. A developer reads code before sentences, so an example at the foot of a section is an example the reader gave up on three paragraphs earlier. The test is whether the code is reachable without scrolling, not whether it is on the first line: a sentence of framing in front of it is fine and sometimes necessary, and three paragraphs of setup are not.

And only where there is something to show. A section arguing a boundary has no snippet to give, and inventing one to satisfy a rule is worse than the prose it padded.

A Users section that names a key, a file or a signature shows it. Not every page can show output: much of this package is designed and not built. The input always exists, because the reader writes it, so a section about x-lifecycle can show three lines of YAML whether or not anything reads them yet.

The gap this closes is measured rather than felt. Laravel's documentation carries about 16 code blocks per thousand words, and this set 0.8. Six of its guides carried none at all when this rule was written, lifecycle.md among them, which spent two thousand words on three specification keys without showing one. Three carry none today.

The order flips for a reader who is deciding rather than doing. On a Contributors page the claim comes first and the example illustrates it, which is what a rule with no example is about. The audience decides this the way it decides the voice.

A table and an example are labelled

A table is introduced by the line above it, and an example carries its own name. Same job as a diagram's caption, and it sits on the other side for a reason: a picture is taken in at a glance and then wants naming, where a table and a block of code are only read if the reader decides they are worth reading. That decision happens before the first row, so the label has to be there before it.

A table's lead says what the table answers. One line above it, in the bolded form this set already uses for the sentence that decides, and a second sentence after it only when the first is not enough. A table whose first row is the first thing a reader learns about it is a table they have to read in full to find out whether they needed it.

An example names its file on its first line, as a comment. // config/auth.php, # openapi.yaml. It is the form that survives being copied: a reader who pastes the block keeps the path it belongs at, which a line above the fence would have left behind. Where there is no file to name, a bolded lead above does the same job.

Not a fence attribute. VitePress renders a title from ```php [app/Models/Comment.php], GitHub renders nothing at all from it, and half of this set's readers are on GitHub. Same reason the anchor rule picks the form that works on both.

The em dash is a decision nobody made

An em dash inside a sentence is a comma, a colon or a full stop that has not been chosen yet. It lets a writer defer saying how two clauses relate, and leaves the reader to work it out. Pick the mark that says what the clause is doing: a comma for an aside, a colon when what follows explains what came before, a full stop when the second half was a sentence wearing a dash.

This set carried 610 of them across its Markdown and carries a handful now. Nothing was lost in the trade, and several sentences turned out to be two. The count is deliberately not given: it moves every time a document is added to the inventory below, and a number in prose that an unrelated change can falsify is stale metadata waiting to happen.

Every one that is left is the same shape, and it is not prose. A list item that opens with a bolded term or a link, with the dash separating it from its description:

markdown
- [`docs/project/stack.md`](./docs/project/stack.md) — every technology choice, its status, and its reasoning.

That is typography a reader scans rather than reads, and it is the only place this set uses the character.

Headings are short claims

A heading carries its claim in as few words as the claim takes. A reader looking for something reads the outline, not the page: they scan a column of headings at speed and stop at the one that answers them. A heading that has to be read like a sentence has stopped being scannable, whatever it says.

Four to six words, and eight is already a section with two subjects in it. Laravel's own documentation runs a median of two words per heading, over 315 of them across four pages, with 2% above six. This set will not get there and should not try: a label is shorter than a claim, and a claim is what this set writes. Six words is what a claim costs. Past eight, the section usually has a second subject in it, or the heading is carrying a subtitle that belongs in its first sentence instead.

And it names what it is about. Short and assertive is not enough on its own. One middleware, one question was both, and the person who wrote it could not say what the section covered when they met it again in the outline. A heading is scanned by somebody looking for a subject, so the subject has to be in it: One middleware checks the scope is the same claim with the thing it is about put back.

This does not soften the rule that a heading asserts something. The doctor's role is shorter than The doctor checks wiring, not rows and worse, because the short one names a topic while the longer one makes a claim. Short and assertive is the target; short and empty is the failure the other rule catches.

And nothing important sits above the first heading. The site builds its outline from ## and ### (outline: [2, 3]), so everything between the page title and the first section is unreachable from it and belongs to no group. Two documents have most of their content there today, one of them the table of every technology choice this project has made. After the summary, reach a heading quickly: a paragraph or two of orientation is fine, and anything past that has earned a heading of its own.

Say what the document does not cover

Every guide names its own limits, in a section of its own. Boundaries are what a reader plans around, and they are the first thing to go quietly stale when a feature grows into what a document once excluded.

This is already the strongest habit in the set, and the rule only makes it expected rather than occasional: the support matrix states what is parsed and not honored, and code-generation/index.md carries both what the build deliberately does not emit and what was decided against. A guide with no such section is claiming it has no edges.

The section's title is not part of the rule. "What this document does not cover" is the plain form and several guides use it, but Row-level rules are a Policy's job and Laravel constraints we do not fight are the same rule kept in the assertive voice the rest of the set writes headings in. What the rule asks for is a section a reader can find, not a phrase to grep for.

A diagram is built, not embedded

Diagrams are PlantUML sources under docs/diagrams/, rendered to an SVG committed beside each one, and referenced from a page as an image. Their shared look, the fonts, the transparent ground and the palette, lives in palette.iuml, which every source includes. It is named .iuml rather than .puml because build-diagrams.sh renders every *.puml in that directory, and a fragment is not a diagram. Neither GitHub nor the site renders PlantUML on its own, and half the readers of this set are on GitHub, so a fenced plantuml block is a code listing to one of them and a diagram to neither.

bash
just diagrams          # render every docs/diagrams/*.puml to the SVG beside it
just diagrams-check    # reports a diagram edited without being rebuilt, writes nothing

scripts/build-diagrams.sh runs PlantUML through Docker, the same shape as Markdown formatting and for the same reason: PlantUML is a Java tool and the development image carries no Java. A PlantUML on your PATH is the fallback for a render and never the preference, because the image is pinned and your copy is not; diagrams-check refuses it outright, since it compares byte for byte and the fonts a local JVM can see move the coordinates. The tool row is in stack.md.

Five rules keep a rendered diagram honest:

  • Commit the source and the SVG, and let CI compare them. A generated file in the tree can drift from what produced it. diagrams-check re-renders into a scratch directory and fails when the committed SVG differs, which turns that drift into a red check rather than a diagram quietly describing an older design. It fails in the other direction too, on an SVG whose .puml was deleted: a picture nothing produces any more would otherwise stay green forever.
  • A change that moves a design moves the diagram that draws it. diagrams-check compares an SVG to its source, and never the diagram to the prose around it, so an image perfectly in sync with its .puml can describe a design from three months ago. That drift is caught in review or it is caught nowhere, and it is the only one that makes a diagram worse than no diagram at all: a picture contradicting its page still reads as the authority.
  • The ground stays transparent, and the color lives in the ink. The SVG is one file serving a light theme and a dark one, with no way of knowing which is active, so a filled background would paint its own ground and be wrong on one of the two. PlantUML settles the question anyway: it has neither alpha nor opacity, so a fill is always opaque. Borders, labels and arrows carry every color this set uses, and the page keeps supplying the ground.
  • Every diagram is captioned, on the line under it. Italic, a short noun phrase naming the picture, and at most one sentence after it when the name is not enough. That is a caption and not a second alt text: the alt describes the image to a reader who cannot see it, the caption names it for one who can and is scanning the page for the picture they remember. The site styles the paragraph immediately after an image as a caption, so the convention is the markup, with no class to remember and nothing that renders as anything but italic prose on GitHub.
  • A diagram never carries a fact alone, and neither does a color. It complements the prose, the table and the rules around it; anything only the picture says is lost to a reader using a screen reader, and to every grep. The same holds one level down: a box painted amber is labelled "generated" in words, so a reader who cannot separate two hues loses nothing. In this set that costs close to nothing: every section already carries its prose, its table and its rules, so a diagram is added beside them rather than paid for by rewriting them.

Seven colors, one meaning each

A color says something, or a diagram does not use it. The seven below are the whole vocabulary, they are defined once in palette.iuml, an absolute link because the site publishes pages and that file is not one, and each one says something this document set already argues in prose:

ColorHexSays
Blue#478ECAShipped by the package
Amber#AC8148Generated, and rewritten on every build
Green#5A9662The project's own, ours to neither write nor rewrite
Violet#A17AB8Third-party, and not ours to change
Red#C8706FA refusal: where the package stops rather than guesses
Yellow#8B8E22A note: the diagram talking about itself
Grey#888888Everything else, which is most of every diagram

All seven sit at the same lightness, and that is what makes one SVG serve both themes. Each is computed at L* 57, the lightness of the grey it joins, so its contrast is the grey's on a white page, on the site's dark ground and on GitHub's, to within a twentieth of a ratio:

AgainstGreyThe six hues
A white page3.543.50 to 3.52
The site's dark4.844.88 to 4.91
GitHub's dark5.345.38 to 5.41

Every number there is computed from the seven values in the table above it, against those three grounds, so a check of an eighth color reproduces them rather than trusting them.

The floor those numbers clear is the 3:1 one, for non-text. WCAG 1.4.11 is what a diagram's boxes, lines and arrows are measured against, and the palette clears it on all three grounds. The labels inside an SVG are a different question: they are real text at 13px, 1.4.3 asks 4.5:1 of text that size, and on a white page this whole palette, the neutral grey it started from included, sits at about 3.5:1. Both dark grounds clear the text floor. The light one does not, and the light one is the default.

That gap is knowing rather than overlooked. The ink is one value serving two themes, so clearing 4.5:1 on a white page would mean a second render of every diagram, which is the cost this whole approach exists to avoid. What makes it survivable is the rule above: nothing is only in the picture, so a label that is hard to read is never the only place a fact lives. The day that stops being true, the ink is what has to move.

An eighth color is computed at that lightness, never picked because it looks right. The property holds only while the set stays isoluminant, and a hue chosen by eye is the one that breaks it on the theme its author was not looking at.

And it is checked against the hues already there, on both grounds. Equal lightness is what makes a color readable; it is not what makes two colors distinguishable. The note yellow was drawn twice for that reason: the first attempt sat close enough to the amber that on a dark ground a note and a generated step stopped being separable at a glance. Same contrast, same legibility, and the wrong answer. Look at the render on both grounds before keeping a hue.

Most of a diagram stays grey. Color marks the thing worth marking, an owner or a refusal, and a diagram with no such distinction to draw is right to carry none, the way operation-lifecycle marks one state and leaves the rest neutral.

When a diagram earns its place

Three steps, or an ordering the prose has to spell out. Below that a sentence wins, and a diagram of two boxes costs a build step to say what a clause already said.

That threshold permits a diagram; it never asks for one. What turns it into something a review can report is the smell an ordering carried by prose alone, which is this rule read from the outside.

A page may carry several diagrams, and depth is what separates them. One image per page is not the rule, because a subject a page explains at two levels is badly served by a single picture drawn at either one. Open with the high-level view; where a later section goes a level deeper, draw that part on its own, and have each one name the other so a reader always knows which level they are looking at. What is never right is two diagrams at the same depth on one page: that is one diagram drawn twice, and the copy is what goes stale. Extending an existing diagram is the default answer there, adding a sibling the exception.

A diagram may draw behavior that does not exist yet, and names the phase inside the image. This set documents a package ahead of its code and says so everywhere in prose, so a picture restricted to what ships today would hide the part a reader most needs to plan around. The undeclared future smell applies to an image exactly as it does to a sentence, and the answer is the same one: say the phase out loud. A PlantUML note is where it goes, because it travels with the diagram rather than sitting in a caption a reader may not reach. A name the repository has not settled is said the same way, in the same note: a picture reads as more settled than the prose it accompanies, so a diagram of working names has to say that is what they are.

The table below separates what is drawn from what would earn a diagram and has not been drawn yet, so that neither is mistaken for the other. A row empty on both sides is a "no" with a reason, kept so that nobody draws one to fill the table:

DiagramShowsDrawnWould earn one
ActivityThe steps of a workflow or an algorithmThe reading pipeline, the decision tree spec:make walks, the ordered steps of a build, the walk of --update-refs, how the doctor classifies a findingNothing else
SequenceThe order of calls between objects or servicesThe path of a requestNothing else
State machineAn entity's lifecycle and the rules that move itAn operation across beta, stable, deprecated and sunsetNothing else
ClassThe concepts and how they relateThe two-class seamThe Contract\ types
ComponentModule and package boundariesThe namespace boundariesNothing else
Use caseWho interacts with the system, and to do whatNothingNothing. Three Artisan commands are a list, not a diagram
Entity relationshipThe tables of a relational databaseNothingNothing. This package has no database
DeploymentThe machines and containers the code runs onNothingNothing. This is a library, and the host is the consumer's

Six subjects clear the step threshold and still do not earn a picture, answered here rather than each time they come up:

SubjectWhy not
The spec:watch loopFour flat steps and no branch. Prose renders it better, and it clears the threshold by a hair
The driver and mapping splitA two-column opposition. The table already there is the right form
The rate limiting windowsConfiguration shapes. The two PHP blocks show them better than an image would
The roadmap phasesA chronology, which this set cuts everywhere else, and it moves on every release. A diagram there would be permanently stale and would demand a render on every roadmap edit
The pagination seamsFour boxes, one of which extends nothing. The table that says so is barely longer than the picture would be, and a diagram this close to the two-box floor does not earn a build step
The glossaryAn index. Nothing to order

Doc smells

Everything above is a rule. This is what it looks like from the outside when one of them is being broken, named so that a review can report a documentation problem the way it reports a code one: a finding with a correction attached, rather than "this file feels heavy".

A smell is a reason to look, not a verdict. A long file that owns one subject is fine; a short one that owns three is not. What each row gives you is the observable sign, so that the judgment happens over something you can point at.

SmellWhat you seeWhat to do
One file, too many subjectscovers lists subjects with nothing in common, and the title no longer describes everything under itSplit it, one topic per file, and leave a link behind
The decision tunnelThe chronology of how a decision was reached, ahead of the decision itselfResult first, reason second, history never
The wall of textA paragraph past roughly ten lines with no bold, no bullet and no subheading anywhere in itAn assertive heading, bold on the sentence that decides, bullets for the cases
The sentence you read twiceYou reached the end of it and went back to the startSplit it, and put the result first
The stacked clauseMore than two commas or conditions carried by one sentenceOne idea per sentence, or bullets when the sentence was really a list
DuplicationThe same reasoning justified in two filesMove it, never copy it, and leave a link. The rule
No summaryFive paragraphs before the reader learns what the thing is forThe summary rule above
Stale metadataA covers claim, a status column or a state paragraph that no longer matches the file under itFix it in the change that made it stale. A Planned row for something already shipped is a lie the table tells on every read
Undeclared futureBehavior that does not exist yet, written in the present tense, with no phase said out loudName the phase at the top of the section, the way the guides already do
A heading that asserts nothing"Overview", "Notes", "Details", "More on this"Put the claim in the heading. The page outline is read as a summary, and these entries spend a line of it saying nothing
A decision wearing a labelA paragraph that opens by announcing a decision rather than stating oneMake the claim the sentence, or the heading. The label is doing a heading's job
Content above the first headingMore than a paragraph or two between the page title and the first ##Give it a heading, or move it under the one that follows. The outline cannot reach it where it is
A rule with no exampleA convention stated in prose, with nothing showing what it looks likeShow the real thing, and say that it is the real thing, so that it cannot quietly go stale
A described shape nobody showsA Users section names a key, a file or a signature and shows none of themShow it, above the paragraph that explains it
A table nobody introducedA table whose first row is the first thing said about itPut a line above it saying what it answers
Borrowed enthusiasmA brochure adjective, or a sentence that addresses the reader before it startsCut it, and put the fact that produced it in its place
The undecided dashAn em dash inside a sentenceChoose the comma, colon or full stop it was standing in for
An ordering carried by prose aloneA section spells out three steps or more, with at least one branch or one refusal among them, and the page carries no imageDraw it, or say why it does not earn one

A decision is a sentence

State what was decided; do not announce that a decision follows. For a long time this set opened those paragraphs with a bolded Decision: prefix, sixty-nine of them across this set, and the prefix did the job a heading should do: it marked the claim as important without making it findable. None of it reached the outline, so a reader looking for what was decided about a subject had to read the page to find out.

Two places carry a decision now, and both are things a reader can navigate to:

  • The sentence itself, bolded the way this set bolds any sentence that decides. **The driver is configured, not detected** says more than **Decision: the driver is configured, not detected** and costs a word less.
  • A heading, when the decision is the subject of its section. ## Pagination is off by default is a decision, and it is in the outline where somebody scanning for it will pass over it.

The reasoning under it belongs in a ### Why … section of its own, for the same reason: a reader who accepts the decision skips it by heading, and a reader who wants to argue finds it by heading.

The reasoning stays, the chronology goes

The decision tunnel is the one smell that gets applied wrongly if the line is not drawn, because this repository documents its reasoning on purpose and that is not what the smell complains about. Every guide here states what it decided and argues for it, and all of that stays.

What it names is narrative, not justification:

  • Keep the decision, the reason that holds it up, and the alternative that was rejected with why it was. A reader who disagrees needs all three to argue with it.
  • Cut the sequence of events that produced it: what was tried first, what an earlier version did, what came up in discussion, what was built and then removed before anything shipped.

The difference is which question the reader is asking. Why is it this way is answered by the reasoning. How did we get here is answered by git, which is better at it than prose and never goes stale.

Formatting

Markdown formatting is not maintained by hand. Run it after editing documentation:

bash
just format-md          # composer format:md, natively
just format-md-check    # reports what needs it, writes nothing

.editorconfig and .prettierrc.json define what it does: line width, indentation, wrapping. Read them there rather than here. The recipes are in the justfile, the tool row is in stack.md.

Everything above this section is about content, and no formatter checks any of it.

The documentation site

Every document in the inventory is published as a page of the site, including the ones at the repository root.

bash
just docs-install   # once
just docs           # local server, hot reload
just docs-build     # static build; fails on a dead internal link
just docs-preview   # serves the built site as it will be published

The same build runs on every pull request, so a dead link is a red check rather than a red deployment.

.vitepress/config.mts decides what is published, in what order and under which group. Page titles come from front matter, so a document is named in one place only. Deployment is .github/workflows/docs.yml, and the tool row is in stack.md.

The theme is the default one, with a stylesheet layered over it. .vitepress/theme/custom.css holds every visual change this site makes, and each rule carries the reason it exists. There is one today, and it follows from how this set writes headings: a heading here is an assertive sentence, which the default right-hand outline truncates to an ellipsis mid-sentence. The outline wraps instead, and widens on a screen with the room for it. A rule there overrides a selector the default theme owns, so keep them few and check them after a VitePress upgrade.

An anchor is slugged like GitHub

Write a same-page link the way GitHub would slug the heading: lowercase it, delete the punctuation, turn the spaces into hyphens. ## Watching: `spec:watch` is #watching-specwatch, with the colons gone rather than turned into hyphens.

That is one form and not two because the site is configured to slug a heading exactly as GitHub does (markdown.anchor.slugify in .vitepress/config.mts, which calls github-slugger rather than reproducing it). Left at its default, the site replaces a punctuation mark where GitHub deletes it, one heading yields two different anchors, and a hand-written link can only ever satisfy one of them. Fourteen dead ones had accumulated that way before anyone looked, so the override is what closed the class rather than a rule asking every author to keep two slug algorithms in their head.

What still gets past the build is a heading renamed while a link to it was not. VitePress checks a link's file and never its fragment, so nothing about that is visible to docs:build:

bash
just docs-build           # first: the check reads the built site
just docs-check-anchors   # every link whose anchor no heading produces

It runs in CI beside the build. It reads the emitted ids rather than re-deriving them from the Markdown, so there is no second implementation of the slug rule to disagree with the first.

A non-heading anchor is HTML

A glossary row is a table cell, and a table cell has no slug. Give it one with an empty anchor element in front of the term, never with a {#id} attribute:

  • Write: | <a id="drift"></a>[Drift](./doctor.md#what-it-checks) | … |
  • Not: | [Drift](./doctor.md#what-it-checks){#drift} | … |

The reason is the same one the rule above serves: one form, and it has to work on both renderers. {#id} is markdown-it-attrs, which VitePress enables and GitHub does not, so on GitHub the braces print as text and no id is produced, and every link into the page lands at the top of it, silently, and docs:check-anchors cannot see it because it reads the built site. The empty anchor survives both: GitHub keeps it, prefixed to user-content-drift, which its own scroll script resolves from #drift, and VitePress passes the raw HTML through untouched.

It is more to type than the attribute, and it is the only part of this that is not free. That is the trade for a link that resolves for the half of this set's readers who are on GitHub.

Document inventory

Each document declares its own title, audience, covers, read_before, and tags in its front matter. Open the file, or grep the tags, to see what it owns. This list intentionally carries no descriptions, so there is nothing here that can fall out of date. It is grouped by audience, because that is what the tree is grouped by.

docs/guide/, for Users:

docs/project/, for Users and contributors:

docs/contributing/, for Contributors:

Repository root:

Adding a Markdown document to the repository means adding it here and giving it front matter.