π Living Architecture Docs: Markdown + Mermaid in Your App
Date: 2026-05-25 Tags: documentation Β· mermaid Β· markdown Β· symfony Β· architecture
π€ Why bother documenting architecture while you build?
Here is an honest scenario: you open a project you have not touched in three months. The code is there, the tests pass, the CI is green. But you cannot remember why the event ticketing module talks to the mailer service before the reservation is confirmed, or what the difference is between Reservation and ReservationType. You end up doing a full archaeological dig through git blame and controller actions β work you already did once.
Architecture documentation written while the code is fresh costs almost nothing. Architecture documentation written six months later costs an afternoon and is still wrong about two things.
The trap most developers fall into is treating docs as a final deliverable, like packaging. In reality they are a development tool β a forcing function that makes you articulate decisions you made instinctively, and a compass that keeps future-you (and teammates) oriented when the codebase has grown to hundreds of files.
graph LR
A[π§ Developer has context] -->|writes docs now| B[π Accurate living docs]
A -->|skips docs| C[π° Developer forgets context]
C -->|three months later| D[π³οΈ Archaeology session]
D --> E[π Docs that are 70% right]
B -->|future developer opens project| F[β
Oriented in 5 minutes]
E -->|future developer opens project| G[β οΈ Partially oriented, partially misled]
Writing a diagram forces you to think about your system as a whole β edges that feel awkward to draw are often a sign of coupling you have not noticed yet. A Mermaid diagram is not just pretty: it is a lightweight architectural review you do on yourself.
πΊοΈ What we built: a Markdown-based internal blog
In SeriousDesign (SD-Sy7) we have an internal documentation and blog system built on top of Markdown files. The controller reads .md files from docs/ subdirectories, parses them with league/commonmark, and renders the HTML result inside a Twig template.
The pipeline looks like this:
sequenceDiagram
participant Dev as π©βπ» Developer
participant FS as π docs/ filesystem
participant Ctrl as MarkdownReaderController
participant Svc as MarkdownParserService
participant CM as league/commonmark
participant Twig as Twig template
participant Browser as π Browser
Dev->>FS: writes .md file
Browser->>Ctrl: GET /docs/blog/2026-05-25/my-post
Ctrl->>FS: file_get_contents(path.md)
FS-->>Ctrl: raw Markdown string
Ctrl->>Svc: parse(markdown, section)
Svc->>Svc: processImages() β rewrites local img paths
Svc->>CM: convert(markdown)
CM-->>Svc: HTML string
Svc-->>Ctrl: HTML string
Ctrl->>Twig: render page.html.twig with content
Twig-->>Browser: full HTML page
This covers headings, tables, task lists, code blocks, and images perfectly. The only thing it does not handle out of the box is Mermaid diagrams.
π§ The Mermaid gap
Mermaid is a JavaScript diagramming library. You write diagram definitions in a text DSL and it renders them as interactive SVGs in the browser. It looks like this in Markdown:
```mermaid
graph TD
A[Start] --> B{Decision}
B -->|yes| C[Do the thing]
B -->|no| D[Skip it]
```
The problem: league/commonmark β like every standard Markdown parser β has no idea what Mermaid is. It sees a fenced code block with language mermaid and faithfully outputs:
<pre><code class="language-mermaid">
graph TD
A[Start] --> B{Decision}
...
</code></pre>
The diagram source is preserved as literal text inside a <code> element. Nothing renders. The browser just shows the raw DSL.
To get from that to an SVG diagram you need two things:
- Transform the DOM β turn
<pre><code class="language-mermaid">into a<pre class="mermaid">element containing only the raw diagram text. - Run Mermaid.js β the library scans for
.mermaidelements and replaces them with SVGs.
π§ The Symfony implementation
In this project (Symfony 8.0, AssetMapper, no Webpack), all we needed to change was a single Twig template: templates/markdown_reader/page.html.twig.
Step 1 β prevent CSS fighting the diagram
The template already had a rule that gives every pre a grey background, a border, and padding. A Mermaid diagram is rendered as an SVG inside that pre β it would look boxed-in and wrong. We add a targeted override:
.markdown-content pre.mermaid {
background-color: transparent;
border: none;
padding: 0;
overflow-x: visible;
text-align: center;
}
Step 2 β load Mermaid and rewire the DOM
We add a <script type="module"> block at the bottom of the template:
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: false, theme: 'default' });
document.querySelectorAll('pre > code.language-mermaid').forEach(el => {
const pre = el.parentElement;
const diagram = document.createElement('pre');
diagram.classList.add('mermaid');
diagram.textContent = el.textContent;
pre.replaceWith(diagram);
});
await mermaid.run();
That is the entire backend-to-diagram pipeline:
graph LR
A["<pre><code class=language-mermaid>"] -->|querySelectorAll| B[JS finds it]
B -->|creates| C["<pre class=mermaid>"]
C -->|mermaid.run| D[πΌοΈ SVG diagram]
Why type="module"? The await mermaid.run() call at the top level requires a module context. It also gives us native ESM imports without a bundler.
Why startOnLoad: false? Mermaid's default behaviour is to scan the DOM automatically on load. We want explicit control β first we rewrite the DOM, then we call run(). Setting it to false prevents a race condition where Mermaid fires before the code block transformation runs.
π Vanilla implementation (any project, any stack)
You do not need Symfony, CommonMark, or AssetMapper. Here is the minimum you need in any HTML page:
The HTML page
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Docs</title>
<style>
pre { background: #f5f5f5; padding: 1rem; border-radius: 4px; }
pre.mermaid { background: transparent; border: none; padding: 0; text-align: center; }
</style>
</head>
<body>
<!-- What a Markdown parser outputs for a mermaid block -->
<pre><code class="language-mermaid">
graph TD
A[User] --> B[Your App]
B --> C[(Database)]
</code></pre>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: false, theme: 'default' });
document.querySelectorAll('pre > code.language-mermaid').forEach(el => {
const pre = el.parentElement;
const diagram = document.createElement('pre');
diagram.classList.add('mermaid');
diagram.textContent = el.textContent;
pre.replaceWith(diagram);
});
await mermaid.run();
</script>
</body>
</html>
That is it. Save it, open it in a browser, and you get a rendered diagram.
Integration by stack
| Stack | Where to put the script block | Notes |
|---|---|---|
| Plain HTML | Before </body> |
Works as shown above |
| Jekyll / Hugo | In your _layouts/post.html or layouts/_default/single.html |
Wrap in a {{ if .Params.mermaid }} guard if you want opt-in |
| Docusaurus | Use the official @docusaurus/theme-mermaid plugin |
Zero config needed |
| MkDocs | Add mermaid2 plugin to mkdocs.yml |
pip install mkdocs-mermaid2-plugin |
| VitePress | Set markdown.config.mermaid: true in config |
Built-in support |
| Next.js / Astro | Create a <MermaidDiagram> client component |
Pass diagram source as prop |
| WordPress | Enqueue the script in functions.php + add the DOM transform |
Or use a plugin |
| Symfony (AssetMapper) | <script type="module"> in the Twig template |
As shown in this post |
| Symfony (Webpack Encore) | import mermaid from 'mermaid' in your entry JS |
Add to importmap or yarn |
The DOM transform explained
graph TB
subgraph Before ["Before mermaid.run()"]
P["<pre>"]
C["<code class='language-mermaid'>"]
T["graph TD\n A --> B"]
P --> C --> T
end
subgraph After ["After transform + mermaid.run()"]
PM["<pre class='mermaid'>"]
SVG["πΌοΈ <svg> diagram"]
PM --> SVG
end
Before -->|"JS rewrites DOM\nthen mermaid.run()"| After
The key insight is that the parser (whether it's CommonMark, marked.js, python-markdown, or anything else) does not need to know about Mermaid at all. The parser does its job β escaping the code block as text β and the browser-side JS does the diagram rendering. Clean separation of concerns.
π¨ Mermaid diagram types worth knowing
Mermaid supports far more than flowcharts. Here is a quick tour of types useful for architecture documentation:
Flowchart / Graph β for decision flows and data flows
graph LR
A[HTTP Request] --> B{Authenticated?}
B -->|yes| C[Controller]
B -->|no| D[Login Redirect]
C --> E[Service Layer]
E --> F[(Database)]
Sequence Diagram β for request lifecycles and API calls
sequenceDiagram
Client->>API: POST /reservations
API->>DB: INSERT reservation
DB-->>API: OK
API->>Mailer: sendConfirmation()
Mailer-->>Client: π§ email
API-->>Client: 201 Created
Entity Relationship β for data models
erDiagram
EVENT ||--o{ RESERVATION_TYPE : has
RESERVATION_TYPE ||--o{ RESERVATION : has
RESERVATION ||--o{ TICKET : generates
USER ||--o{ RESERVATION : makes
Pie chart β for quick breakdowns
pie title Codebase distribution
"Controllers" : 22
"Entities" : 30
"Services" : 15
"Templates" : 25
"Tests" : 8
π‘ Architecture documentation tips
After wiring up diagram support, here is how to get the most out of it:
β Do
- Write the diagram the same day you design the feature. It takes 10 minutes when the architecture is in your head and an hour when it is not.
- Use sequence diagrams for anything async β emails, queued jobs, webhooks. They make the order of operations explicit in a way prose cannot.
- Link from code to docs. A comment with a path to the relevant doc is much more durable than a Slack thread.
- Keep diagrams close to the code they describe. A diagram in
docs/blog/that describessrc/Controller/EventManagement/is easier to maintain than one in a separate wiki. - Treat outdated diagrams as bugs. An architecture diagram that no longer matches the code actively misleads people.
β Avoid
- Mega-diagrams that try to show everything. One diagram per concern. A 40-node flowchart is harder to read than four 10-node ones.
- Documenting what the code does instead of why. The code already says what it does. Docs should explain the reasoning, the trade-offs, and the non-obvious constraints.
- Waiting until the project is done. The project is never done. Write as you go.
π What we have now
With the changes described in this post, this blog system supports:
| Feature | Status |
|---|---|
| Markdown headings, lists, blockquotes | β Always worked |
| Tables | β
Via TableExtension |
| Task lists | β
Via TaskListExtension |
| Syntax-highlighted code blocks | β Browser-side (no server highlight) |
| Local image serving | β
Via docs_image route |
| Mermaid diagrams | β Added today |
| Table of contents | β JS-generated from headings |
| Mobile-responsive layout | β Bootstrap + sticky nav |
The full rendering chain, end to end:
graph TB
subgraph Author ["βοΈ Author"]
MD["Markdown file\n(with mermaid blocks)"]
end
subgraph Server ["π₯οΈ Symfony Server"]
Ctrl["MarkdownReaderController"]
Svc["MarkdownParserService\n(league/commonmark)"]
HTML["HTML string\n(pre > code.language-mermaid)"]
Twig["page.html.twig"]
end
subgraph Browser ["π Browser"]
Raw["Rendered HTML"]
JS["Script rewrites DOM"]
Mermaid["Mermaid.run()"]
SVG["β¨ SVG Diagrams"]
end
MD --> Ctrl --> Svc --> HTML --> Twig --> Raw
Raw --> JS --> Mermaid --> SVG
Two files changed. Zero new PHP dependencies. Every Markdown file across the entire docs/ tree now renders diagrams automatically.
A good architecture diagram is worth a thousand words of prose β and a thousand words of prose written today are worth ten thousand words you will struggle to reconstruct next year.