Search by

devlin / laravel-model-analyzer

dev-lin2

Analyze and validate Eloquent relationships against database schema

Package info

github.com/dev-lin2/laravel-model-optimizer

pkg:composer/devlin/laravel-model-analyzer

Statistics

Installs: 38

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v3.2.1 2026-09-08 08:21 UTC

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 --strict is 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 errors
  • 1 — 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