›
byrcsc/laravel-cartographer · 1.x
Call the classes behind cartographer:erd from application code.
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.
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.
DiagramGenerator is what both commands run. Hand it a GenerationSettings
and it discovers models, reads the schema once, and returns every diagram the
settings declare:
use Byrcsc\Cartographer\DiagramGenerator;
use Byrcsc\Cartographer\GenerationSettings;
$settings = GenerationSettings::fromArray(config('cartographer'));
foreach (app(DiagramGenerator::class)->generate($settings, base_path()) as $diagram) {
echo "{$diagram->path}: {$diagram->entityCount} entities, {$diagram->edgeCount} edges\n";
}Use this when you want the command's behaviour with a different destination for the output. Use the stage classes above when you want a different pipeline.
public function __construct(SchemaReader $schemaReader, DiagramExporter $exporter);
/** @return non-empty-list<GeneratedDiagram> */
public function generate(
GenerationSettings $settings,
string $basePath,
?Closure $warn = null,
bool $render = true,
): array;Returns one GeneratedDiagram per target, in target order: the main diagram
first, then each configured group. Models are discovered and the schema is read
once however many diagrams come out of them.
$warn receives one message per skipped model, unmatched discovery path, and
crossed renderer limit. Model-level warnings are reported against the first
target only, because they describe the models rather than any one diagram.
$render set to false produces the Mermaid source and the paths without
rendering an artifact. That is what cartographer:check uses, and it is why
checking an exported diagram needs no mermaid-cli.
$diagrams = app(DiagramGenerator::class)->generate(
$settings,
base_path(),
fn (string $message) => logger()->warning($message),
render: false,
);public static function fromArray(array $config): self;
public function with(GenerationOverrides $overrides): self;
/** @return non-empty-list<DiagramTarget> */
public function targets(): array;
public function groupOutput(DiagramGroup $group): string;
public function mermaidConfig(): array;
public function themePreset(): ?ThemePreset;
public function rendersImages(): bool;
public function sourcePathFor(string $output): ?string;
public static function resolvePath(string $output, string $basePath): string;Everything a run needs, already validated. fromArray reads the package config
shape and throws InvalidArgumentException with the messages listed in
configuration. with layers command line
overrides on top, where a null on the override leaves the configured value
alone.
targets() is where a group becomes a file. It throws when --group is
combined with a scope option or --output, and when the named group is not
configured.
use Byrcsc\Cartographer\GenerationOverrides;
$settings = GenerationSettings::fromArray(config('cartographer'))
->with(new GenerationOverrides(format: 'svg', theme: 'dracula'));Constants worth naming: FORMATS, IMAGE_FORMATS, COLUMN_MODES,
DEFAULT_OUTPUT, DEFAULT_FORMAT, and DEFAULT_COLUMN_MODE.
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();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.
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.
public function __construct(
RelationshipDetector $detector,
?Closure $warn = null,
);
public function assemble(
array $models,
DatabaseSchema $schema,
array $seeds = [],
?int $depth = null,
array $excludeRelations = [],
?array $scope = null,
): 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.
$scope is what a group and --only are built on. A non-null list limits the
diagram to those models, and a relation leaving the scope keeps its edge with
the foreign entity emitted as a stub: a GraphEntity with isStub true, no
columns, and no edges of its own. null means the whole application.
$graph = $assembler->assemble(
$models,
$schema,
scope: ['App\Models\Invoice', 'App\Models\Payment'],
);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.
public function __construct(array $columnExcludes = []);
public function render(
Graph $graph,
string $columns = 'all',
string $format = 'markdown',
): string;
public function diagram(Graph $graph, string $columns): string;
public function wrap(string $diagram, string $format): 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');render() is diagram() followed by wrap(). The two are separate because an
image export needs the bare Mermaid source to hand the renderer and the wrapper
only applies to text output.
interface DiagramExporter
{
public function export(string $mermaid, ExportRequest $request): string;
}MermaidCliExporter is the bundled implementation, and it is what the
container resolves DiagramExporter to. Swap it in a service provider to render
through something else:
$this->app->bind(DiagramExporter::class, MyExporter::class);final class MermaidCliExporter implements DiagramExporter
{
public const int EXPORT_MAX_TEXT_SIZE = 100_000_000;
public const int EXPORT_MAX_EDGES = 1_000_000;
public const float TIMEOUT_SECONDS = 300.0;
public function __construct(MermaidCliLocator $locator);
}It writes the source and a config file to a temporary directory, runs
mermaid-cli, and returns the rendered bytes. The two size constants are why an
export is not subject to the limits hosted renderers
enforce. Every failure is a GenerationException: a missing binary, a non-zero
exit, a timeout, or a successful run that wrote nothing.
public const string INSTALL_HINT;
public function locate(?string $configured, string $basePath): string;Returns a path to a mermaid-cli binary, searching the configured path, then
node_modules/.bin/mmdc under $basePath, then PATH. Recognises mmdc,
mmdc.cmd, and mmdc.exe. Throws GenerationException when a configured path
is not executable, and when nothing is found anywhere.
final class ThemeResolver
{
public const string DEFAULT_THEME = 'light';
public const string DEFAULT_FONT = 'mono';
/** @var array<string, string> */
public const array FONTS;
/** @var array<string, array{font: string, themeVariables: array<string, string>}> */
public const array BUILT_IN;
/** @param array<string, ThemePreset> $presets */
public function resolve(array $presets, string $theme, ?string $font = null): ThemePreset;
}final readonly class ThemePreset
{
public function __construct(
public string $name,
public array $themeVariables,
public string $font = ThemeResolver::DEFAULT_FONT,
);
public function mermaidConfig(): array;
public function backgroundColor(): ?string;
public function withFont(string $font): self;
}Pure transformation: no filesystem and no renderer, so a theme is resolvable
and testable without mermaid-cli installed. resolve() prefers a user preset
over a built-in of the same name and throws InvalidArgumentException listing
what is available when a name matches neither.
mermaidConfig() returns ['theme' => 'base', 'themeVariables' => [...]] with
the font stack merged in as fontFamily. backgroundColor() is the canvas a
PNG rasterizes against: background, then mainBkg, then null.
public const int DEFAULT_MAX_TEXT_SIZE = 50000;
public const int DEFAULT_MAX_EDGES = 500;
public function __construct(
?int $maxTextSize = self::DEFAULT_MAX_TEXT_SIZE,
?int $maxEdges = self::DEFAULT_MAX_EDGES,
);
/** @return list<string> */
public function warnings(string $diagram, int $edgeCount): array;warnings() takes the raw Mermaid source, without any Markdown wrapper, and
returns a message per limit crossed. An empty list means everything is within
its limits. null on either constructor argument switches that check off; a
non-positive integer throws InvalidArgumentException.
Nothing here modifies the diagram. See renderer limits.
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.
Every one is final readonly with public properties.
public array $entities; // list<GraphEntity>
public array $edges; // list<GraphEdge>
public function entity(string $name): ?GraphEntity;public string $name; // table name
public ?string $modelClass; // null for pivot tables
public TableSchema $schema;
public bool $isStub; // true for a model outside the scopeA stub is how a scoped diagram stays honest about its boundaries. It carries a
name and nothing else, so an edge leaving a group or an --only set still
points somewhere without dragging the far side of the schema in.
public string $path; // where the artifact goes
public string $mermaid; // the raw Mermaid source
public ?string $contents; // the rendered artifact, null when render: false
public string $comparison; // what cartographer:check compares
public int $entityCount;
public int $edgeCount;
public array $warnings; // list<string>
public ?string $group; // null for the main diagram
public ?string $sourcePath; // the .mmd beside an exported image
public function artifact(): string;
public function comparedPath(): string;One result from DiagramGenerator::generate(). artifact() returns
$contents and throws GenerationException when the run did not render one.
comparedPath() is $sourcePath when there is one and $path otherwise,
which is how an image is checked through its source rather than its bytes.
final readonly class DiagramGroup
{
public string $name;
public array $models; // list<class-string<Model>>
public ?string $output; // null lands it beside the main diagram
}
final readonly class DiagramTarget
{
public string $output;
public ?array $scope; // list<string>|null, null is the whole application
public ?string $group; // null for the main diagram
}A DiagramGroup is a validated config entry. A DiagramTarget is one file a
run will produce, and GenerationSettings::targets() turns the groups into
them.
public string $format; // svg or png
public string $basePath;
public ?string $binary; // cartographer.export.mermaid_cli
public array $mermaidConfig; // from ThemePreset::mermaidConfig()
public ?string $backgroundColor; // from ThemePreset::backgroundColor()Everything DiagramExporter::export() needs beyond the Mermaid source itself.
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;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 onlyWhich properties are populated depends on the type; see the table in relationship detection.
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.
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.
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.
| Exception | Extends | Thrown by |
|---|---|---|
SchemaReadException | RuntimeException | SchemaReader::read() |
GraphAssemblyException | RuntimeException | GraphAssembler::assemble() |
GenerationException | RuntimeException | MermaidCliLocator::locate(), MermaidCliExporter::export(), GeneratedDiagram::artifact() |
InvalidArgumentException | none | MermaidRenderer::render(), GenerationSettings, ThemeResolver, ThemePreset, RendererLimits |
RuntimeException | none | AtomicFileWriter::write() |
Both commands catch InvalidArgumentException and RuntimeException, which
covers all five, print the message, and exit 1.
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.
MermaidCliExporter.MermaidRenderer produces.