A Laravel ERD should be drawn from the database that php artisan migrate built, not from the migration files. The files are a history written in PHP, and the database is the result of running it: what a conditional decided, what a raw SQL string created, what a squashed schema dump replaced. We measured the gap on Invoice Ninja v5.13.47, an open-source Laravel 12.69.3 app with 317 migration files: on an empty MySQL 8.4 database, php artisan migrate loaded a stored schema dump and then ran only 61 of them.
The comparison came from readers. Under a video comparing Laravel ERD tools, the most-liked comment asked what happens to the diagram over the lifetime of a project, and the replies split between drawing the diagram from the migration files and reading the database itself. Both camps want a diagram that stays true; they disagree on which source is the truth.
What does a fresh php artisan migrate actually run?
On an empty database Laravel runs the stored schema dump first and then only the migrations the dump does not already record, so most migration files never execute.
Laravel’s migration docs describe it plainly: when no migrations have been executed, Laravel “will first execute the SQL statements in the schema file of the database connection you are using”, then “any remaining migrations that were not part of the schema dump”. Invoice Ninja ships that file as database/schema/mysql-schema.sql, and the migrate output says so:
INFO Loading stored database schemas.
database/schema/mysql-schema.sql ........................... 1s DONE
INFO Running migrations.
Afterwards the migrations table holds 319 rows: 258 inserted by the dump, recorded as already run, and 61 that actually ran on top of it. The other 256 files are still in database/migrations and do not run, including the 1,397-line 2014_10_13_000000_create_users_table.php, whose 50 Schema::create calls once built most of the schema. On a fresh install none of them execute.
Why is generating an ERD from Laravel migration files unreliable?
Because the files are PHP that has to run to mean anything, and a reader that only parses them has to reproduce Laravel’s schema builder, its conditionals and every raw SQL string.
Invoice Ninja’s migrations show each kind of logic a parser has to follow:
| What the migration does | Files | Example |
|---|---|---|
| Alters an existing table | over 190 | Schema::table('invoices', ...) |
| Branches on the current schema | 15 | if (!Schema::hasColumn('invoices', 'gateway_fee')) |
| Drops a column | 18 | $table->dropColumn('e_invoicing_token') |
| Renames a column or table | 13 | renameColumn, Schema::rename |
| Runs raw SQL | 3 | DB::statement('CREATE INDEX client_hash_idx ON clients (client_hash(20));') |
The raw SQL line is the one that matters most. client_hash(20) is a MySQL prefix index, written as a string inside PHP, so a parser of the schema builder never sees it. It is also the index that made MySQL refuse to rebuild Invoice Ninja when an earlier version of Schemity dropped its prefix length, which is how our MySQL round trip on Invoice Ninja found it.
The conditionals are worse for a diagram than the raw SQL. if (!Schema::hasColumn(...)) means the same file produces different schemas on different databases, so the only way to know which branch ran is to look at the database it ran against.
Is the schema dump a good source for a Laravel ERD?
It is an exact picture of the day it was taken and of nothing after it: Invoice Ninja’s dump is 10 tables, 43 columns and 13 foreign keys behind the database the same release builds.
php artisan schema:dump writes the output of the database’s own dump client (MariaDB, MySQL, PostgreSQL and SQLite only), so it is real DDL rather than PHP, and it imports cleanly. Measured against the migrated database:
mysql-schema.sql | Database after migrate | |
|---|---|---|
| Last migration recorded | October 3 2024 | September 9 2026 |
| Tables | 70 | 80 |
| Columns | 1,586 | 1,727 |
| Foreign keys | 125 | 138 |
The ten tables missing from the dump are e_invoicing_logs, e_invoicing_tokens, elastic_migrations, job_batches, locations, passkey_credentials, referral_earnings, taggables, tags and verifactu_logs, and 43 columns were added to tables the dump does have. A diagram drawn from the dump is a diagram of Invoice Ninja in October 2024.
Laravel migrations to ERD: which source should the diagram come from?
The migrated database, because it is the only one of the three that is the schema rather than a description of how to build one.
| Source | What it is | What it misses | What it needs |
|---|---|---|---|
| Migration files | 317 PHP files, over 14,000 lines | Whatever the conditionals and raw SQL decide; the dump that replaces 256 of the files | A parser that behaves like Laravel |
schema:dump file | One SQL file from the database’s dump client | Every migration after the day it was dumped | Re-dumping after each migration |
Database after migrate | The tables, columns, keys and indexes that exist | Laravel-only meaning: model names, casts, Eloquent relations | A database; a local one is enough |
The cost of the database route is that you need one, and a local MySQL or PostgreSQL in Docker plus one php artisan migrate is enough. The cost of the file route is a parser that has to know about the dump, read SQL hidden in PHP strings, and guess which branch of each hasColumn ran on a database whose history differs from a fresh install.
What does the database know that the migrations never said?
That almost half of Invoice Ninja’s relations have no foreign key: the database declares 138, and 131 more exist only as column names, so no source draws them unless something reads the names.
Those columns were never given a key. activities is created with $table->unsignedInteger('client_id')->nullable(); and an index, and the line that would have made it a foreign key is commented out in the migration:
//$table->foreign('client_id')->references('id')->on('clients')->onDelete('cascade')->onUpdate('cascade');
The same is true of any Laravel app, whatever helper it uses. Laravel’s docs say foreignId “creates an UNSIGNED BIGINT equivalent column”; the constraint comes from ->constrained(), which appears five times in Invoice Ninja’s 317 files; a column created without it, or with its foreign() line left out, is just a column. A migration parser and a schema dump both see exactly what the database sees here: a column, an index and no relation.
The relations are still in the names. Invoice Ninja’s database has 218 _id columns without a key, and Schemity reads them: it infers 131 relations from columns in 37 tables, such as activities.client_id to clients and credits.design_id to designs, each drawn dashed and tagged as inferred, never written into a migration. The other 87 stay plain columns. Most name no table at all: status_id (10 columns) and frequency_id (5) hold constants from the PHP models, such as Invoice::STATUS_DRAFT = 1, and others hold ids from outside services, such as the mail provider’s message_id and the bank feed’s nordigen_transaction_id. Some name a table but disagree with its key, and inference skips a pair whose types differ: activities.purchase_order_id is int unsigned against a bigint unsigned key on purchase_orders, and bank_transactions.expense_id is text. And four are the id half of a polymorphic pair, such as paymentable_id beside paymentable_type, which points at a different table on every row. While testing this post Schemity inferred paymentables.paymentable_id as a key to paymentables itself; it now skips the id half of any _type pair, a fix that ships in the next release.

On a smaller view the two kinds are easy to tell apart. In the context view of the Activity & ledger tables, backups.activity_id is a declared foreign key and is drawn solid, while company_ledgers.activity_id was never declared and is drawn dashed, read from its name.

Drawing a Laravel ERD in Schemity
Run the migrations into a local database, connect Schemity to it, and refresh after every php artisan migrate, so the diagram is always read from what ran.
Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. Connect it to the MySQL database your Laravel app migrates, and it draws the tables, the declared foreign keys, the indexes with their prefix lengths, and the inferred relations on top, each one tagged as read from column names. A Virtual relations menu shows or hides them with a count, removes the inferred ones in one go, and a connection can turn inference off entirely. That is how you understand your schema when the code that built it is 14,000 lines of history.

After the next php artisan migrate, Refresh & merge re-reads the database and keeps every position, colour, legend and line route you set, with new tables arriving dashed, as one unsaved change you can inspect before saving. A diagram you designed before the tables existed works the same way: connect it to a database that already has them, and the merged diagram opens as an unsaved change, so closing without saving leaves your file as it was.
When there is no database at hand, Import SQL reads the schema dump instead. On Invoice Ninja’s mysql-schema.sql it read all 70 tables with nothing left unread, without counting the dump’s DROP TABLE statements or its INSERT INTO migrations rows as skipped, and importing a newer dump onto the same diagram marks each table New, Updated or Unchanged and never removes one. It is still a picture of the day the dump was taken. Imported onto the diagram read from the migrated database, Invoice Ninja’s dump finds no new table and marks 29 of its 70 tables Updated: users, for one, has 44 columns in the dump and 46 in the database, which added referral_earnings and settings after October 2024. On a connected diagram, importing it would make those 29 tables pending changes that take the database back to the dump.

The other direction is a trade-off rather than a feature. Schemity writes SQL, a migration diff for a connected database, not a Laravel migration file; DrawSQL exports Laravel migrations, as our DrawSQL comparison says. The route that works today is the one in ERD-first database design: design the change on the diagram, hand the SQL to your coding agent to write the Laravel migration, run it, and refresh, so the dashed draft tables turn solid without moving.
Two details above have their own posts: the prefix index client_hash(20) is what broke our MySQL round trip on Invoice Ninja and Sylius, and polymorphic associations explains why a column like paymentable_id can never carry a foreign key.
Frequently asked questions
Can I generate an ERD from Laravel migrations without running them?
Only by re-implementing what Laravel does when it runs them. Migration files are PHP: Schema::hasColumn conditionals, raw DB::statement SQL and the stored schema dump decide what the database ends up with, and in Invoice Ninja 256 of 317 files never run on a fresh install. Running php artisan migrate against a local database and drawing the result is shorter and exact.
Does php artisan schema:dump keep the ERD up to date?
It is accurate on the day you run it and goes stale with every later migration. Invoice Ninja's database/schema/mysql-schema.sql records migrations up to October 2024; the same release adds 61 migrations on top, so the dump has 70 tables and 125 foreign keys against 80 tables and 138 foreign keys in the migrated database.
Why does my Laravel ERD show relations with no foreign key?
Because the column was created without one. foreignId('user_id') alone creates only an UNSIGNED BIGINT column; the constraint comes from ->constrained() or ->foreign(), and an unsignedInteger('client_id') has none either. A diagram can still draw those relations from the column names, as long as it marks them as inferred rather than declared.