TL;DR — I built an interactive ERD for a 112-table Laravel schema: one self-contained HTML file, generated from the live database rather than from the migrations. Three of the four bugs that cost me real time rendered successfully — including the one where every table card drew perfectly and all 161 relations silently vanished. The whole thing is now a Claude Code skill in nasrulhazim/claude 2.4.0.
The document I actually wanted
Every ERD generator I looked at reads your Eloquent relations and emits a static image. That's fine right up until you use it, because the question people open an ERD to ask is not "what does this table point at" — the columns already tell you that. It's "what points at this table, and what breaks if I change it?"
A PNG can't answer that. So: a React Flow island with dagre auto-layout, bundled into a single HTML file.
Self-contained is the whole requirement. The document lives in docs/, gets opened straight off a filesystem, attached to an email, or read on a machine with no network. So React, React Flow, dagre, the stylesheet and the schema payload are all inlined. The schema especially — from file:// every request is cross-origin, so a fetch() of the sibling JSON is refused.
That comes to 995 KB. For a document that still opens in five years with no toolchain, I'll take it.
Read the result, not the instructions
The first real decision: parse database/migrations, or introspect the database?
Introspect. Always. The migrations are the instructions; only the database is the result. This project has 134 migration files, and a good number of them change a column, swap an index, or drop something added three releases ago. Their net effect is precisely what a parser gets wrong — and it gets it wrong quietly, which is the worst possible failure mode for a document people are about to trust.
foreach (Schema::getTables() as $table) {
if (($table['schema'] ?? $schema) !== $schema) {
continue;
}
$names[] = $table['name'];
}
That filter is not defensive programming. On Laravel 13, Schema::getTables() returns every table on the server, not on the connection's own database. My dev machine runs several projects against one MySQL daemon, so the first honest run reported around fifteen hundred tables.
The cost of introspection is that generating needs a migrated database. That's why the JSON payload is committed: the HTML rebuilds on any machine with Node and no database at all, and a schema change becomes a readable diff — which a 995 KB generated HTML file emphatically is not.
The map that refuses to guess
Each table is coloured by the product domain it serves. Thirteen domains, and the map from table to domain is written by hand.
I tried the obvious shortcut first. A name-prefix heuristic files deployment_mail_sandboxes under Deployment rather than Data Services, and node_certificates under Infrastructure rather than Networking. It's wrong maybe five percent of the time — and it never tells you which five percent.
That's the actual problem. A diagram that quietly mis-files a table is worse than one that's missing it, because the reader can't tell which happened.
So the map is explicit, and coverage is checked in both directions:
public static function unmapped(array $tables): array
{
return array_values(array_diff($tables, array_keys(self::TABLES)));
}
public static function stale(array $tables): array
{
return array_values(array_diff(array_keys(self::TABLES), $tables));
}
unmapped() catches the migration whose table nobody classified. stale() catches the rename that updated the migration and not the map. The generate command refuses to write when either is non-empty, and a Pest test fails the suite for the same reason — the command is only run by whoever regenerates the diagram, but the suite is run by everyone.
The friction is the feature. Adding a table costs you ten seconds of deciding what it is, on the pull request where you still know.
The same restraint runs through what gets drawn. Only declared foreign keys become edges — a relation living in an Eloquent method has nothing in the schema to draw, so the About panel says so rather than letting you assume a table with no arrows is an island. And a polymorphic pair gets a dashed auditable_* tag instead of an arrow, because auditable_type + auditable_id resolve at runtime: an arrow to any one table would be a claim the schema does not make.
Then every relation disappeared
Here's the one that cost most of the session.
The diagram rendered. All 112 cards, correct columns, correct colours, correct layout, minimap populated. Zero edges. No exception, no console warning, no React error boundary. Nothing.
The cause is four steps deep and every step is reasonable:
- I gave each node
widthandheight, because I already knew the dimensions and dagre needs them before anything is measured. - Declaring the box tells React Flow the node is already measured, so it skips the DOM measurement pass.
- Handle bounds are computed during that pass — so
handleBoundsstaysundefined. -
getEdgePositionreturns null,EdgeWrapperreturns null for the edge, and the edge is dropped in silence.
The fix is one word in two places:
- width: NODE_W,
- height: nodeHeight(table, rows),
+ initialWidth: NODE_W,
+ initialHeight: nodeHeight(table, rows),
Same numbers, seeded the same way — but the node still gets measured. And you do want to seed them, because the minimap reads the user node via getNodeDimensions(userNode) rather than the internals, and nothing writes measured back onto the object your app owns. An unseeded node draws no minimap rectangle.
And the browser lied about it. Twice.
I diagnosed that bug wrong twice before getting it right, and the reason is worth more than the bug.
I was driving Chrome from a script and asserting on the DOM. A Chrome tab that isn't painting throttles ResizeObserver and requestAnimationFrame. Nodes stay visibility: hidden, fitView never lands, and a perfectly healthy page reports as broken.
Look at that symptom list again: no visible nodes, no fitted view, no edges. It is indistinguishable from the real bug. So the throttled tab kept confirming a diagnosis I'd already made, and I kept fixing the wrong thing.
The rule I wrote down: take a screenshot first — that forces a frame — and only then trust what the DOM says. A cheap forced repaint before any assertion is the difference between measuring the page and measuring the browser's power-saving behaviour.
The test that would have passed forever
Last one, and it's my favourite kind: the gate that couldn't fail.
The table filter above originally compared against getDatabaseName(). That works on MySQL. On SQLite — which is what the test suite runs against — the schema is main while the database name is the file path. They never match. Every table gets skipped.
Which means the coverage tests, the ones whose entire job is to fail when a table is unclassified, were passing against an empty schema. Green, fast, and completely hollow.
$schema = Schema::getCurrentSchemaName();
One method call. A gate that cannot fail isn't a gate — it's decoration with a green tick on it.
Shipping it
The whole toolchain is two commands, deliberately split so neither needs the other's toolchain:
php artisan docs:erd # live database -> erd-schema.json (needs a DB, no Node)
npm run build:erd # JSON -> the HTML (needs Node, no DB)
Both outputs are committed, and I packaged the lot as a schema-diagram skill plus an /erd command in nasrulhazim/claude 2.4.0 — extractor, domain map, the React Flow island, the stylesheet, the Pest suite.
The reference files carry the findings, not just the code, because the code was never the hard part. Anyone can write a component that draws a box. Knowing that width silently costs you every edge is the part that took a day.
Takeaway: every expensive failure in this build looked exactly like success — a clean render with no edges, a green suite over an empty schema, a browser confidently reporting a healthy page as broken. None of them threw anything.
So the rule I'm keeping is about what a generated document must be able to do: refuse. Mine refuses to write when a table is unclassified, refuses to draw an edge the schema doesn't declare, refuses to point an arrow at a polymorphic target, and records the migration head it was read at so a reader can check it against the repo. Everything a document asserts that it cannot verify is decoration — and decoration is what people believe right up until it costs them.

Top comments (0)