TL;DR — nasrulhazim/claude 2.4.0 ships a schema-diagram skill and an /erd command that generate a self-contained interactive ERD from a Laravel project's live database. The interesting part isn't the diagram. It's that every bug I hit on the way there rendered successfully and was wrong.
The category of bug that actually costs you
There's a class of failure that never throws. No stack trace, no red text, no failing assertion. The thing builds, the page paints, the suite goes green — and the output is a lie.
I spent a day building an ERD document for a production Laravel app and hit five of them in a row. That's what got distilled into the skill. The templates are the boring half; the references carry the findings.
Here's the catalogue.
1. The test suite that passed against an empty schema
On Laravel 13, Schema::getTables() returns every table on the server, not 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.
Obvious fix: filter on the schema.
$schema = Schema::getDatabaseName(); // wrong
That works on MySQL and PostgreSQL. It quietly destroys everything on SQLite — where the schema is main and the database name is the file path. Nothing matches. tableNames() returns [].
Now trace what that does to the coverage tests, which run against the test database:
it('files every table in the schema under a domain', function () {
$unmapped = ErdDomains::unmapped(ErdSchema::tableNames());
expect($unmapped)->toBe([]);
});
Zero tables in, zero unmapped out, green tick. The gate whose entire job is to catch an unclassified table now certifies an empty schema, forever, and never mentions it.
The fix is one word:
$schema = Schema::getCurrentSchemaName();
And a structural rule alongside it: build() delegates to tableNames() so there's exactly one code path deciding what "the schema" means. Two implementations of that filter drifting apart is the same bug with a longer fuse.
This is the one I'd flag hardest in review. A green suite that asserts over an empty collection is worse than a failing one, because the failing one tells you something.
2. Migrations are the instructions. The database is the result.
Every ERD package I looked at parses database/migrations or reads Eloquent relations. Both are the wrong input.
A mature project's migration folder is a history: columns added then changed then dropped, indexes swapped, tables renamed. The net effect after a hundred-odd files is precisely what a parser gets wrong — and again, silently. It produces a schema. Just not yours.
So: introspection only.
php artisan docs:erd # database -> JSON (needs a DB, no Node)
npm run build:erd # JSON -> HTML (needs Node, no DB)
The split is deliberate. Either half runs alone, and because the JSON payload is committed, the document rebuilds on a machine that has never touched the database. The JSON is also the thing that makes schema drift reviewable — git diff docs/03-architecture/erd-schema.json in a PR beats squinting at a regenerated 1 MB HTML blob.
One nasty edge worth naming: migration.repository->getRan() ends on whatever sorts last, and 9999_12_31_* sentinel migrations are a real convention. Take the last dated one, or your document reports a migration head from the year 9999.
3. A prefix heuristic mis-files tables and gives no sign
Grouping tables into domains by name prefix is the tempting shortcut. deployment_* goes to Deployment, done.
Except deployment_mail_sandboxes probably belongs to Data Services, and the heuristic will never tell you it guessed. A diagram that quietly mis-files a table is worse than one that's missing it — the reader can't tell which happened.
So the map is hand-written, and the gate runs in both directions:
/** Tables the database has that the map does not. */
public static function unmapped(array $tables): array
{
return array_values(array_diff($tables, array_keys(self::TABLES)));
}
/** Tables the map still claims that the database no longer has. */
public static function stale(array $tables): array
{
$known = array_diff(array_keys(self::TABLES), ['operations']);
return array_values(array_diff($known, $tables));
}
unmapped catches the new migration nobody classified. stale catches the rename that updated the migration and forgot the map. You need both; one alone rots.
Then it gets pinned twice — once in the command, once in the suite:
it('does not claim tables the schema no longer has', function () {
$stale = ErdDomains::stale(ErdSchema::tableNames());
expect($stale)->toBe([], sprintf(
'Remove %s from App\Support\Erd\ErdDomains::TABLES.',
implode(', ', $stale)
));
});
That's not redundancy. The command is only run by whoever is regenerating the diagram. The suite is run by everyone — so it fails on the pull request that adds the table, while the author still remembers what the table is for, instead of six weeks later when someone opens the diagram and meets a stranger.
The rule I put in the skill in capital letters: never add a default => arm to make the gate pass. The friction is the feature.
4. width instead of initialWidth
This is my favourite, because the symptom is so beautifully misleading.
Give a React Flow node width/height and React Flow takes you at your word — it skips measurement. Handle bounds are never computed. Every edge that depends on a handle is dropped.
In silence. No warning, no console error, no partial render.
What you see is a canvas of table cards, laid out perfectly, styled correctly, fully interactive — and not one relation drawn. It looks like a data problem. You go and audit your foreign key extraction, which is fine.
// wrong — box is set, measurement is skipped, handles never exist
{ width: 280, height: 160 }
// right — box is seeded AND still measured
{ initialWidth: 280, initialHeight: 160 }
Same story with detail levels. Switch a card from Names → Keys → All and the edge count must not change. An edge naming a handle that's no longer rendered is deleted without a word. So that's a checklist item, not a hope.
5. The healthy page that reports as broken
Then the trap that wasted the most time, because it mimics the one above.
An unpainted Chrome tab throttles ResizeObserver and requestAnimationFrame. Nodes stay unmeasured, fitView never lands, and any JS assertion you run against the DOM comes back with — exactly — the signature of the width/height bug. On a page that is completely fine.
The rule: take a screenshot first. That forces a frame. Then trust the DOM.
I'd have called that superstition a week earlier. It's the single most useful line in the verification checklist now.
What the skill actually is
Seven subcommands over the whole lifecycle:
| Command | Does |
|---|---|
/erd init |
Scaffolds the toolchain into a project that has none |
/erd generate |
php artisan docs:erd — live database into the JSON payload |
/erd build |
npm run build:erd — JSON into the standalone HTML |
/erd refresh |
Generate + build + stage both. The everyday command after a migration |
/erd check |
Domain coverage gate, writes nothing |
/erd domains |
Review or extend the map alone |
/erd verify |
Walk the checklist in a real browser |
Two rules in there are about restraint rather than correctness, and I think they matter more:
Never migrate a database you weren't asked to migrate. A diagram of a half-migrated schema is worse than no diagram — but a surprise migration on someone's dev database is worse than both. Build in a throwaway, drop it, and say plainly in the summary that you did.
mysql -e "CREATE DATABASE erd_tmp"
DB_DATABASE=erd_tmp php artisan migrate --force
DB_DATABASE=erd_tmp php artisan docs:erd
mysql -e "DROP DATABASE erd_tmp"
Write the companion .md next to the HTML. A generated megabyte of HTML isn't reviewable in a PR, doesn't render on GitHub, and doesn't show up in a grep. The markdown page — link, counts, the domain table, what it does not draw, the two regeneration commands — is what the docs tree indexes and what a reviewer actually reads.
Two things the diagram refuses to draw
Worth stating, because both are places where being helpful would mean lying:
-
Polymorphic pairs get a tag, never an edge.
auditable_type+auditable_idresolve at runtime and can point at any model. An arrow to any one table is a claim the schema does not make. - A table with no arrows is not an island. Only declared foreign keys become edges. A relation living in an Eloquent method has nothing in the schema to draw. Say so in the About panel instead of inventing edges.
Same principle both times: draw what the schema asserts, name what it doesn't, and let the reader know which is which.
The takeaway
The theme across all five isn't ERDs. It's that the expensive bugs in a generated artifact are the ones with no error surface — the green suite over an empty collection, the perfect render with the data missing, the map that guessed and didn't say.
So the design response is the same every time: make the ambiguous case fail loudly, at the moment someone still has context. That's why the domain map has no fallback arm, why the gate runs in both directions, why it's wired into both the command and the suite, and why the verification checklist is a list of things that render successfully and are wrong.
If you want it: nasrulhazim/claude 2.4.0, /erd init in any Laravel project. The database-engineer agent loads the skill automatically for schema documentation work.
What I'd watch out for next: the domain map is hand-maintained by design, and that's fine at 8–14 domains. Past a hundred tables the map stops being the bottleneck and legibility does — no zoom level makes that many cards readable, which is arithmetic, not a layout defect. The answer there is what the reader meets first: open on the legend, and let the filters be the way in.
Top comments (0)