Browse documentationOpen

byrcsc/laravel-cartographer · 1.x

PHP API.

The classes behind the command, their constructors and methods, the data objects they return, and how to drive the pipeline yourself.

Every stage of the command is a class you can use directly. Reach for the API when one Artisan invocation is not the right shape: several diagrams from one schema read, a graph inspected before it is rendered, or a diagram assembled inside a command of your own.

All classes live in the Byrcsc\Cartographer namespace.

The pipeline in one script

use Byrcsc\Cartographer\AtomicFileWriter;
use Byrcsc\Cartographer\GraphAssembler;
use Byrcsc\Cartographer\MermaidRenderer;
use Byrcsc\Cartographer\ModelDiscovery;
use Byrcsc\Cartographer\RelationshipDetector;
use Byrcsc\Cartographer\SchemaReader;

$models = (new ModelDiscovery([app_path('Models')]))->discover();
$schema = app(SchemaReader::class)->read();

$assembler = new GraphAssembler(new RelationshipDetector);
$renderer = new MermaidRenderer;
$writer = app(AtomicFileWriter::class);

$scopes = [
    'docs/erd.md' => [[], null],
    'docs/erd-publishing.md' => [['Post'], 1],
];

foreach ($scopes as $path => [$seeds, $depth]) {
    $graph = $assembler->assemble($models, $schema, $seeds, $depth);

    $writer->write(base_path($path), $renderer->render($graph, 'keys'));
}

The schema is read once and reused. That is the main reason to drop to the API: cartographer:erd reads the schema on every invocation, which is fine for a handful of diagrams and wasteful for twenty.

Classes

ModelDiscovery

public function __construct(
    array $paths,
    array $excludeModels = [],
    ?Closure $warn = null,
);

public function discover(): array;

$paths are glob patterns matched against directories. $excludeModels are model class names. $warn receives one string message per pattern that matches no directory; pass null to discard warnings.

discover() returns a list of model class names, sorted, with duplicates removed.

$models = (new ModelDiscovery(
    [app_path('Models'), base_path('src/Domain/*/Models')],
    [App\Models\Telemetry::class],
    fn (string $message) => logger()->warning($message),
))->discover();

SchemaReader

public function __construct(
    DatabaseManager $database,
    ?string $configuredConnection = null,
    SchemaTypeNormalizer $typeNormalizer = new SchemaTypeNormalizer,
);

public function read(?string $connection = null): DatabaseSchema;

public function normalizeType(string $type): string;

The container binds this as a singleton preconfigured with cartographer.connection, so app(SchemaReader::class) is the usual way in. read() takes an explicit connection that overrides both.

read() throws SchemaReadException when the connection cannot be opened or introspected, with the driver exception attached as the previous exception.

normalizeType() exposes the type mapping on its own, for a column you looked up elsewhere. It applies the non-key branch of the integer rule.

RelationshipDetector

public function __construct(bool $strictTypesOnly = false);

public function detect(string $modelClass): array;

Returns a list of Relationship objects for one model, sorted by method name. Methods that throw are skipped.

foreach ((new RelationshipDetector)->detect(App\Models\Post::class) as $relation) {
    printf(
        "%s: %s -> %s (fk=%s pivot=%s morph=%s)\n",
        $relation->name,
        $relation->type,
        $relation->relatedModel ?? 'null',
        $relation->foreignKey ?? '-',
        $relation->pivotTable ?? '-',
        $relation->morphName ?? '-',
    );
}
attachments: morph_many -> App\Models\Attachment (fk=attachable_id pivot=- morph=attachable)
author: belongs_to -> App\Models\User (fk=author_id pivot=- morph=-)
category: belongs_to -> App\Models\Category (fk=category_id pivot=- morph=-)
comments: has_many -> App\Models\Comment (fk=post_id pivot=- morph=-)
tags: belongs_to_many -> App\Models\Tag (fk=- pivot=post_tag morph=-)

This is the class to use when you want relation metadata for something other than a diagram — a documentation generator, a lint rule, a dependency report.

GraphAssembler

public function __construct(
    RelationshipDetector $detector,
    ?Closure $warn = null,
);

public function assemble(
    array $models,
    DatabaseSchema $schema,
    array $seeds = [],
    ?int $depth = null,
    array $excludeRelations = [],
): Graph;

$seeds are short or fully qualified model class names. $depth is a hop limit, or null for unlimited. $excludeRelations are type names and the through and morph group aliases.

Throws GraphAssemblyException for a negative depth, an unknown seed, an ambiguous seed, and a seed whose table does not exist. $warn receives one message per skipped model.

MermaidRenderer

public function __construct(array $columnExcludes = []);

public function render(
    Graph $graph,
    string $columns = 'all',
    string $format = 'markdown',
): string;

$columnExcludes maps model class names to lists of column names, matching cartographer.columns.exclude. $columns is all, keys, or none; $format is markdown or mmd. Either one outside its set throws InvalidArgumentException.

$diagram = (new MermaidRenderer([
    App\Models\User::class => ['remember_token'],
]))->render($graph, 'keys', 'mmd');

AtomicFileWriter

public function __construct(Filesystem $files);

public function write(string $path, string $contents): void;

Creates missing directories, writes to a temporary file in the destination directory, and moves it into place. On any failure it removes the temporary file and throws RuntimeException with the message Unable to write ERD to [path].

Paths are used exactly as given; there is no project-root resolution, which the command does before calling this.

Data objects

Every one is final readonly with public properties.

Graph

public array $entities;   // list<GraphEntity>
public array $edges;      // list<GraphEdge>

public function entity(string $name): ?GraphEntity;

GraphEntity

public string $name;         // table name
public ?string $modelClass;  // null for pivot tables
public TableSchema $schema;

GraphEdge

public string $source;      // source table name
public string $target;      // target table name
public string $type;        // belongs_to, morph_many, ...
public string $name;        // relation method name
public ?string $morphName;
public ?string $pivotTable;

Relationship

public string $name;
public string $type;
public ?string $relatedModel;      // null for morph_to
public ?string $foreignKey;
public ?string $ownerKey;
public ?string $pivotTable;
public ?string $pivotForeignKey;
public ?string $pivotRelatedKey;
public ?string $morphName;
public ?string $intermediateModel; // through relations only

Which properties are populated depends on the type; see the table in relationship detection.

DatabaseSchema and TableSchema

final readonly class DatabaseSchema
{
    public array $tables;  // list<TableSchema>

    public function table(string $name): ?TableSchema;
}

final readonly class TableSchema
{
    public string $name;
    public array $columns;      // list<SchemaColumn>
    public array $indexes;      // list<SchemaIndex>
    public array $foreignKeys;  // list<SchemaForeignKey>

    public function column(string $name): ?SchemaColumn;
    public function hasPrimaryColumn(string $name): bool;
    public function hasUniqueColumn(string $name): bool;
    public function foreignKeyFor(string $column): ?SchemaForeignKey;
}

$indexes holds primary and unique indexes only. Plain indexes are read during introspection — they affect type normalization — but they are not kept, because nothing downstream renders them.

SchemaColumn, SchemaIndex, SchemaForeignKey

final readonly class SchemaColumn
{
    public string $name;
    public string $type;      // normalized
    public bool $nullable;
}

final readonly class SchemaIndex
{
    public string $name;
    public array $columns;    // list<string>
    public bool $unique;
    public bool $primary;
}

final readonly class SchemaForeignKey
{
    public string $name;
    public array $columns;             // list<string>
    public string $referencedTable;
    public array $referencedColumns;   // list<string>
}

The name on an index or a foreign key is derived, not the database's own name. It is built from the columns so that the same structure produces the same name on every driver:

index names: primary:id
fk names: foreign:author_id->users(id) | foreign:category_id->categories(id)

Use it for sorting and comparison. Do not use it to drop a constraint.

SchemaTypeNormalizer

public function normalize(
    string $typeName,
    string $nativeType,
    bool $keyColumn,
): string;

$typeName is the driver's type name, $nativeType its full declaration (tinyint(1)), and $keyColumn whether the column takes part in any index or foreign key. Pass a custom instance to SchemaReader to change the mapping.

Exceptions

ExceptionExtendsThrown by
SchemaReadExceptionRuntimeExceptionSchemaReader::read()
GraphAssemblyExceptionRuntimeExceptionGraphAssembler::assemble()
InvalidArgumentExceptionMermaidRenderer::render() on a bad mode or format
RuntimeExceptionAtomicFileWriter::write()

The command catches InvalidArgumentException and RuntimeException, which covers all four, prints the message, and exits 1.

Stability

These classes are the package's public surface, and the test suite asserts that each one exists under its documented name. Constructor signatures and return types are covered by semantic versioning; the private helpers inside them are not.