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.mdhonest. When a choice moves fromUndecidedorPlannedtoDecided, 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 iscomposer 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:
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.mtsnames it in asequenceby its directory name, like any other page. - The directory answers on its own URL, so
/docs/guide/code-generationkeeps 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-anchorsis 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.
audience | Lives in | Answers |
|---|---|---|
Users | docs/guide/ | How to use the package, and what it promises. |
Users and contributors | docs/project/ | Where the project is going, and what it is built on. |
Contributors | docs/contributing/ | Why it is built this way, and how to work on it. |
AI coding agents | AGENTS.md | How 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.
agentsis 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 howUsers, contributors and agentscame to mean nothing.AGENTS.mdis 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.mdandCONTRIBUTING.mdstay at the root because GitHub and every contributor expect them there.CONTRIBUTING.mdis aContributorsdocument that happens to live outsidedocs/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
coversclaim 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.
audience | Sounds like |
|---|---|
Users | Second 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 contributors | A statement of where things stand. A row is a fact with a status on it, never a pitch, and a date beats an adjective. |
Contributors | A 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 agents | Imperative 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 notworkbench/; 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.
---
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]
---| Field | Required | Purpose |
|---|---|---|
title | yes | Human-readable name. May differ from the filename. |
audience | yes | Who the document is written for. One of four values, which also fixes the directory. |
covers | yes | The subjects this file owns. If two files claim the same subject, one of them is wrong. |
read_before | no | The action that should trigger reading this file. This is what makes the set navigable to an agent that has never seen the repository. |
tags | yes | Subject 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
coversis for. - A new Markdown document without front matter is an incomplete change, with one exception below.
coversis a claim of ownership. Before adding a subject to a file, check that no other file already claims it. Overlappingcoversfields 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:
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, notCode 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.
audienceis already its own field. - Adding a new tag means adding it to the vocabulary below, in the same change.
Tag vocabulary
| Tag | Subject |
|---|---|
agents | How AI agents should work in this repository |
ci | Continuous integration and the build matrix |
code-generation | Producing PHP from the specification, and the rules that keep it safe to re-run |
code-review | Review priorities and how findings are reported |
commands | The Artisan commands this package ships, and how they are invoked |
compatibility | What the package honors of a standard, and the promise attached to it |
conventions | Commit, naming, and style conventions |
contributing | How to contribute: setup, pull requests, conduct |
decisions | Choices made, with their reasoning |
dependencies | Third-party packages and version constraints |
docker | The containerised development environment |
documentation | How documentation itself is written and organized |
drivers | The driver extension mechanism and the features built on it |
laravel | Laravel version support and framework integration |
metadata | Front matter and document metadata |
migration | Moving an existing Code-First app to Spec-First |
onboarding | Getting a newcomer or a fresh agent productive |
openapi | The OpenAPI specification and its parsing |
pagination | Paging a collection: how a specification declares one and how the package reads it |
php | PHP version support and language constraints |
planning | Roadmap, phases, and sequencing |
rate-limiting | Rate limits: how the specification declares one and how the package reads it |
scope | What belongs in this package and what does not |
security | Authentication, authorization, and the boundary of what the spec can express |
stack | Technology choices |
testing | Test levels, expectations, and the test suite |
versions | Supported and required versions |
workflow | The 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_beforewould read "anything else",audiencewould read "anyone", andcoverswould 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:
> **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 briefin every document adds one entry of pure noise per page. A blockquote also renders on GitHub, which is whereAGENTS.mdandCONTRIBUTING.mdare 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.mdis 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:
coversis 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:buildto 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:
- [`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.
just diagrams # render every docs/diagrams/*.puml to the SVG beside it
just diagrams-check # reports a diagram edited without being rebuilt, writes nothingscripts/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-checkre-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.pumlwas 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-checkcompares an SVG to its source, and never the diagram to the prose around it, so an image perfectly in sync with its.pumlcan 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:
| Color | Hex | Says |
|---|---|---|
| Blue | #478ECA | Shipped by the package |
| Amber | #AC8148 | Generated, and rewritten on every build |
| Green | #5A9662 | The project's own, ours to neither write nor rewrite |
| Violet | #A17AB8 | Third-party, and not ours to change |
| Red | #C8706F | A refusal: where the package stops rather than guesses |
| Yellow | #8B8E22 | A note: the diagram talking about itself |
| Grey | #888888 | Everything 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:
| Against | Grey | The six hues |
|---|---|---|
| A white page | 3.54 | 3.50 to 3.52 |
| The site's dark | 4.84 | 4.88 to 4.91 |
| GitHub's dark | 5.34 | 5.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:
| Diagram | Shows | Drawn | Would earn one |
|---|---|---|---|
| Activity | The steps of a workflow or an algorithm | The reading pipeline, the decision tree spec:make walks, the ordered steps of a build, the walk of --update-refs, how the doctor classifies a finding | Nothing else |
| Sequence | The order of calls between objects or services | The path of a request | Nothing else |
| State machine | An entity's lifecycle and the rules that move it | An operation across beta, stable, deprecated and sunset | Nothing else |
| Class | The concepts and how they relate | The two-class seam | The Contract\ types |
| Component | Module and package boundaries | The namespace boundaries | Nothing else |
| Use case | Who interacts with the system, and to do what | Nothing | Nothing. Three Artisan commands are a list, not a diagram |
| Entity relationship | The tables of a relational database | Nothing | Nothing. This package has no database |
| Deployment | The machines and containers the code runs on | Nothing | Nothing. 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:
| Subject | Why not |
|---|---|
The spec:watch loop | Four flat steps and no branch. Prose renders it better, and it clears the threshold by a hair |
| The driver and mapping split | A two-column opposition. The table already there is the right form |
| The rate limiting windows | Configuration shapes. The two PHP blocks show them better than an image would |
| The roadmap phases | A 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 seams | Four 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 glossary | An 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.
| Smell | What you see | What to do |
|---|---|---|
| One file, too many subjects | covers lists subjects with nothing in common, and the title no longer describes everything under it | Split it, one topic per file, and leave a link behind |
| The decision tunnel | The chronology of how a decision was reached, ahead of the decision itself | Result first, reason second, history never |
| The wall of text | A paragraph past roughly ten lines with no bold, no bullet and no subheading anywhere in it | An assertive heading, bold on the sentence that decides, bullets for the cases |
| The sentence you read twice | You reached the end of it and went back to the start | Split it, and put the result first |
| The stacked clause | More than two commas or conditions carried by one sentence | One idea per sentence, or bullets when the sentence was really a list |
| Duplication | The same reasoning justified in two files | Move it, never copy it, and leave a link. The rule |
| No summary | Five paragraphs before the reader learns what the thing is for | The summary rule above |
| Stale metadata | A covers claim, a status column or a state paragraph that no longer matches the file under it | Fix 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 future | Behavior that does not exist yet, written in the present tense, with no phase said out loud | Name 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 label | A paragraph that opens by announcing a decision rather than stating one | Make the claim the sentence, or the heading. The label is doing a heading's job |
| Content above the first heading | More 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 example | A convention stated in prose, with nothing showing what it looks like | Show the real thing, and say that it is the real thing, so that it cannot quietly go stale |
| A described shape nobody shows | A Users section names a key, a file or a signature and shows none of them | Show it, above the paragraph that explains it |
| A table nobody introduced | A table whose first row is the first thing said about it | Put a line above it saying what it answers |
| Borrowed enthusiasm | A brochure adjective, or a sentence that addresses the reader before it starts | Cut it, and put the fact that produced it in its place |
| The undecided dash | An em dash inside a sentence | Choose the comma, colon or full stop it was standing in for |
| An ordering carried by prose alone | A section spells out three steps or more, with at least one branch or one refusal among them, and the page carries no image | Draw 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 defaultis 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:
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.
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 publishedThe 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:
just docs-build # first: the check reads the built site
just docs-check-anchors # every link whose anchor no heading producesIt 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:
code-generation/index.mdcommands.mdcontrollers.mddoctor.mddrivers.mdglossary.mdlifecycle.mdopenapi-support.mdpagination.mdrate-limiting.mdremote-references.mdsecurity.md
docs/project/, for Users and contributors:
docs/contributing/, for Contributors:
documentation.md— this file
Repository root:
README.md— no front matter, by designCHANGELOG.md—Users and contributorsCONTRIBUTING.md—ContributorsAGENTS.md—AI coding agentsLICENSE— MIT, plain text, no front matter
Adding a Markdown document to the repository means adding it here and giving it front matter.