Disclosure: I build Schemity, a desktop ERD tool - this post is from our blog and uses it for the examples.

TL;DR: Draw a Laravel ERD from the database that php artisan migrate built, not from the migration files. In Invoice Ninja v5.13.47 a fresh migrate loads a squashed schema dump and then runs only 61 of the 317 files, and that dump is 10 tables and 43 columns behind the release that ships it. And neither source shows almost half the relations: the database declares 138 foreign keys, while 131 more live only in column names such as activities.client_id.

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.

Invoice Ninja's migrated database opened in Schemity: a notification reads 131 virtual relations were inferred from column names, drawn dashed and not declared in the database; dashed lines run between tables such as webhooks, company_tokens and accounts among the solid declared foreign keys, and the status bar shows Entities: 80, Relations: 269, MySQL 8.4.11

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.

Schemity context view Activity & ledger on Invoice Ninja: six tables, with a solid relation from backups.activity_id to activities, a declared foreign key, and a dashed relation from company_ledgers.activity_id to activities, inferred from the column name; activities.client_id shows as a plain column because clients is outside the view, and the status bar shows Entities: 6, Relations: 2

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.

Schemity's Virtual relations menu open over Invoice Ninja's main diagram: Show virtual relations (131) ticked and Remove inferred virtual relations (131); behind it the Expenses & projects legend with tasks, recurring_expenses, projects and expenses joined by solid declared foreign keys and many dashed inferred relations, and the status bar showing Entities: 80, Relations: 269

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.

Schemity's Import SQL drawer previewing Invoice Ninja's mysql-schema.sql on the diagram read from the migrated database: tables such as subscriptions, tasks, transaction_events, users with 44 columns, vendor_contacts and vendors marked Updated, others such as sizes, system_logs, tax_rates and webhooks Unchanged, an info box saying updated tables take the script's columns and become pending changes on a connected diagram, Infer virtual relations from column names ticked, and the button Import (0 new, 29 updated)

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.