devlin / laravel-model-analyzer
Analyze and validate Eloquent relationships against database schema
Requires
- php: ^8.1
- illuminate/console: ^9.0|^10.0|^11.0|^12.0
- illuminate/database: ^9.0|^10.0|^11.0|^12.0
- illuminate/support: ^9.0|^10.0|^11.0|^12.0
- symfony/finder: ^6.0|^7.0
Requires (Dev)
- mockery/mockery: ^1.4|^1.5|^1.6
- orchestra/testbench: ^7.0|^8.0|^9.0|^10.0
- phpunit/phpunit: ^9.3|^10.0|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Laravel package that scans your Eloquent models and validates their relationships against your actual database schema. It detects missing inverse relationships, circular dependencies, missing foreign key columns, missing indexes, and more — then reports a health score for your model layer.
Demo
You can check a quick demo here
Requirements
| Dependency | Version |
|---|---|
| PHP | ^8.1 |
| Laravel / Illuminate | ^9.0 – ^12.0 |
| Symfony Finder | ^6.0 or ^7.0 |
Installation
Install via Composer:
composer require devlin/laravel-model-analyzer
Laravel's package auto-discovery registers the service provider automatically. If you have auto-discovery disabled, add the provider manually in config/app.php:
'providers' => [ Devlin\ModelAnalyzer\ModelAnalyzerServiceProvider::class, ],
Publish the config file
php artisan vendor:publish --provider="Devlin\ModelAnalyzer\ModelAnalyzerServiceProvider"
This creates config/model-analyzer.php.
Configuration
// config/model-analyzer.php return [ // Directories where your Eloquent models live 'model_paths' => [ app_path('Models'), app_path(), // Laravel < 8 ], // Fully-qualified class names to skip 'excluded_models' => [ 'Illuminate\Notifications\DatabaseNotification', ], // Database tables to skip 'excluded_tables' => [ 'migrations', 'failed_jobs', 'password_resets', 'personal_access_tokens', ], // Database connection to use (null = default connection) 'database_connection' => null, // When true, warnings are treated as errors (non-zero exit code) 'strict_mode' => env('MODEL_ANALYZER_STRICT', false), // Point weights used to calculate the 0–100 health score 'health_weights' => [ 'has_inverse' => 30, 'no_circular' => 30, 'column_exists' => 20, 'has_index' => 10, 'has_foreign_key' => 10, ], ];
Commands
model-analyzer:analyze
Runs a full analysis of all discovered models and prints a report.
php artisan model-analyzer:analyze
Options:
| Option | Description |
|---|---|
--format=cli |
Output format: cli (default) or json |
--strict |
Exit with code 1 if any warnings are found |
--models=User,Post |
Analyze only the specified models (comma-separated) |
--tables=users,posts |
Analyze only the specified tables (comma-separated) |
--detail |
Show the full issue list with messages and suggestions (default output is summary counts) |
--debug |
Print model-by-model progress, for troubleshooting a failing scan |
Examples:
# Default CLI report php artisan model-analyzer:analyze # JSON output (pipe-friendly, useful in CI) php artisan model-analyzer:analyze --format=json # Fail CI if any warnings exist php artisan model-analyzer:analyze --strict # Analyze a single model php artisan model-analyzer:analyze --models=User
Exit codes:
0— no errors (warnings are allowed unless--strictis used)1— errors found, or warnings found with--strict
model-analyzer:health
Displays a summary health score and grouped recommendation report.
php artisan model-analyzer:health
Exit codes:
0— no errors1— one or more errors detected
model-analyzer:list-models
Lists all Eloquent models discovered in the configured paths.
php artisan model-analyzer:list-models
Options:
| Option | Description |
|---|---|
--with-relationships |
Show relationship count per model |
--json |
Output as a JSON array of fully-qualified class names |
Examples:
php artisan model-analyzer:list-models php artisan model-analyzer:list-models --with-relationships php artisan model-analyzer:list-models --json
model-analyzer:visualize
Generates a visual diagram of your model relationships as a standalone file.
php artisan model-analyzer:visualize
Options:
| Option | Description |
|---|---|
--output=path |
Output file path (default: model-relationships.html or model-erd.html) |
--models=User,Post |
Comma-separated list of models to include |
--erd |
Generate an Entity Relationship Diagram instead of a force-directed graph |
--format=html |
Output format: html (interactive, D3.js) or svg (static, embeddable) |
--source=database |
Schema source for ERDs: database, migrations, or both |
--no-models |
Pure schema output: no model names or relationships, and app classes are never loaded |
--schema-only |
Alias for --no-models |
--issues=all |
Which notices to print: all, errors, warnings, none |
--hide-errors |
Never print error notices |
--hide-warnings |
Never print warning notices |
Examples:
# Interactive HTML graph (default) php artisan model-analyzer:visualize # ERD with table boxes, columns, and crow's foot cardinality php artisan model-analyzer:visualize --erd # Static SVG — embeddable in docs, READMEs, presentations php artisan model-analyzer:visualize --format=svg # SVG ERD for specific models php artisan model-analyzer:visualize --erd --format=svg --models=User,Post # Custom output path php artisan model-analyzer:visualize --format=svg --output=docs/models.svg # ERD built from migration files - no database connection needed php artisan model-analyzer:visualize --erd --source=migrations # ERD built from the live connection php artisan model-analyzer:visualize --erd --source=database
--source and the ERD. Without --source, the ERD is model-driven and shows one box per
Eloquent model. With --source, it becomes table-first: every table in the chosen schema
appears, including pivots and tables with no model, and models decorate the tables they map to.
--source applies to --erd only; the force-directed graph is always model-driven.
HTML format produces a standalone file with D3.js — drag nodes, zoom, hover for details.
SVG format produces a pure <svg> file with no JavaScript — lightweight, scalable, and works anywhere images are supported.
model-analyzer:docs
Generates a data dictionary — a reference describing the schema as it is: tables, columns, types, nullability, keys, indexes, foreign keys, and the Eloquent relationships of any model that maps to a table.
php artisan model-analyzer:docs
Options:
| Option | Description |
|---|---|
--source=database |
Schema source: database, migrations, or both |
--format=md |
Output format: md (Markdown) or html |
--style=table |
Presentation: table (data dictionary) or prose (definitions) |
--output=path |
Output file path (default: schema-docs-<source>.<ext>) |
--models=User,Post |
Restrict model enrichment to these models |
--no-models |
Skip Eloquent model discovery entirely — never loads your app's classes |
--issues=all |
Which notices to print: all, errors, warnings, none |
--hide-errors |
Never print error notices |
--hide-warnings |
Never print warning notices |
Examples:
# Markdown data dictionary from the live database php artisan model-analyzer:docs --source=database # From migration files only - works with no database connection php artisan model-analyzer:docs --source=migrations --output=docs/schema.md # Styled HTML, light and dark aware php artisan model-analyzer:docs --format=html --output=public/schema.html # Prose definitions instead of column tables php artisan model-analyzer:docs --style=prose --output=docs/definitions.md # Pure schema: tables, columns, keys and indexes only - nothing about models php artisan model-analyzer:docs --schema-only --output=docs/schema.md
Schema-only output
--schema-only (or --no-models) produces a document about the database and nothing else:
table names, column details, key references and indexes, with no model names and no Eloquent
relationships. Application classes are never loaded, which also makes it immune to broken
classes in app/.
## orders | Column | Type | Nullable | Key | Default | References | |---|---|---|---|---|---| | `id` | bigint unsigned | no | PRI | — | — | | `user_id` | bigint unsigned | no | — | — | `users.id` | | `coupon_id` | bigint unsigned | yes | — | — | — | **Indexes** - `orders_user_id_index` (user_id)
Tables that simply have no model never carry a model line either — their absence is not announced.
Two presentations
--style=table (the default) produces a data dictionary: one row per column, with type,
nullability, key and references. Best when you want to look something up.
--style=prose produces definitions: each table described in sentences, with no column grid.
Best when you want to read the schema rather than search it. --style=definitions is accepted
as an alias.
## orders `orders` is backed by the `App\Models\Order` model. It holds 5 columns, keyed by `id`. Required values: `user_id`. Each row references one `users` record via `user_id` and optionally references one `coupons` record via `coupon_id`. Referenced by `order_product` via `order_id`. The table supports soft deletion via `deleted_at`. `user_id` and `coupon_id` have a foreign key but no index. The model declares 2 relationships: `user()` (BelongsTo), `items()` (HasMany).
Every statement is derived from the schema itself — column counts, keys, foreign keys in both
directions, Laravel's timestamp and soft-delete conventions, uniqueness and index coverage.
Facts the source does not know are left unsaid rather than guessed, so migration-sourced
definitions are shorter than database-sourced ones. It describes structure, not business
meaning: it can say a row references users, but not what a status value signifies.
model-analyzer:report
Reports findings rather than describing the schema: columns that should have a foreign key but don't, foreign key candidates with no supporting index, and drift between two sources.
php artisan model-analyzer:report
Options:
| Option | Description |
|---|---|
--source=database |
Schema source: database, migrations, or both |
--format=cli |
Output format: cli, md, json, or html |
--output=path |
Write to a file instead of stdout |
--issues=all |
Which notices to include: all, errors, warnings, none |
--hide-errors |
Never include error notices |
--hide-warnings |
Never include warning notices |
--fail-on-findings |
Exit non-zero when findings exist (for CI) |
Examples:
# Human-readable summary in the terminal php artisan model-analyzer:report # Compare migrations against the live database and show drift php artisan model-analyzer:report --source=both # Machine-readable output for tooling php artisan model-analyzer:report --format=json --output=build/schema.json # Gate a pipeline on findings (opt-in; exits 0 otherwise) php artisan model-analyzer:report --source=both --fail-on-findings
Missing foreign key detection. A column is reported when it looks like a foreign key
(*_id) and a plausible target table actually exists and no constraint already covers
it. Requiring the target to exist keeps false positives low: logs.external_id with no
externals table is skipped. Polymorphic columns (a *_id paired with a *_type) are excluded
by design, since they cannot carry a single-table constraint. Each finding includes the
migration line that would fix it.
Schema Sources
Every schema-aware command accepts --source:
| Value | Reads from | Needs a database? |
|---|---|---|
database (default) |
The live connection, via information_schema or sqlite_master |
Yes |
migrations |
Static parsing of your migration files — no code is executed | No |
both |
Both of the above, and reports drift between them | Partially |
migrations makes the whole toolset usable in CI and on a fresh checkout where no database has
been provisioned.
Degraded Output, Never A Crash
These commands are built not to break. A missing database connection, an absent migration
directory, an unparseable migration, or an unwritable output path produces empty or "missing"
output plus a notice — never an exception, and never a non-zero exit code unless you opt in
with --fail-on-findings.
Broken models in your app
Model discovery has to let PHP load your model classes, and a class whose trait, parent or
interface cannot be resolved raises an uncatchable fatal error when PHP links it — no
try/catch can intercept that. So the scanner resolves each model's traits, parent and
interfaces statically first, and skips any model that would not link, reporting it as a
warning:
warning Skipped App\Admin: unresolved dependency (Notifiable). Fix the imports in Admin.php.
Your schema output is unaffected — only that one model's enrichment is missing.
If you would rather not touch application classes at all, --no-models skips discovery
entirely. Schema output is identical; tables simply render without model names or Eloquent
relationships:
php artisan model-analyzer:docs --source=database --no-models php artisan model-analyzer:visualize --erd --source=database --no-models
One limit worth knowing: if a parent class has the broken dependency, the static check
resolves the parent's name successfully and the fatal moves one level up. Deeply broken class
chains are not fully protected — use --no-models there.
Notice visibility is controlled per run:
# Everything (default) php artisan model-analyzer:report --issues=all # Errors only, or warnings only php artisan model-analyzer:report --issues=errors php artisan model-analyzer:report --issues=warnings # Silence notices entirely php artisan model-analyzer:report --issues=none # Or suppress one class at a time php artisan model-analyzer:report --hide-warnings
What It Detects
| Issue | Severity | Description |
|---|---|---|
| Missing inverse relationship | Warning | e.g. User hasMany Post exists but Post belongsTo User is missing |
| Circular dependency | Error | Two models reference each other in a way that creates a loop |
| Missing foreign key column | Error | A relationship references a column that does not exist in the database |
| Missing index on foreign key | Warning | A foreign key column has no index, which can hurt query performance |
| Missing foreign key constraint | Warning | A *_id column has no FK constraint although its target table exists (model-analyzer:report) |
| Unindexed foreign key candidate | Warning | A likely foreign key column has neither a constraint nor an index (model-analyzer:report) |
| Schema drift | Warning | A table or column exists in the database but not in the migrations, or the reverse (--source=both) |
The first four are reported by model-analyzer:analyze and model-analyzer:health, which are
model-driven. The last three come from model-analyzer:report, which is schema-driven and so
also sees tables that have no Eloquent model.
Health Score
The model-analyzer:analyze and model-analyzer:health commands calculate a 0–100 health score based on the number and severity of issues found relative to the total number of relationships. A score of 100 means no issues were detected.
CI Integration
Run the analyzer in strict mode to fail your pipeline when any issue is found:
# GitHub Actions example - name: Analyze models run: php artisan model-analyzer:analyze --strict
Or allow warnings but fail only on errors (the default):
- name: Analyze models run: php artisan model-analyzer:analyze
Without a database
--source=migrations reads your migration files directly, so schema checks run on a fresh
checkout with no database provisioned:
- name: Check for missing foreign keys run: php artisan model-analyzer:report --source=migrations --fail-on-findings - name: Publish schema docs run: php artisan model-analyzer:docs --source=migrations --output=docs/schema.md
model-analyzer:report exits 0 by default no matter what it finds; --fail-on-findings is what
makes it gate a pipeline. To keep the log quiet, add --issues=none.
Catching schema drift
With a database available, --source=both compares your migrations against the live schema and
fails when they have diverged:
- name: Detect schema drift run: php artisan model-analyzer:report --source=both --fail-on-findings
Running Tests
composer test
License
MIT