Browse documentationOpen

byrcsc/laravel-cartographer · 1.x

Relationship detection.

How relations are resolved by invoking model methods, which relation types are recognised, what strict mode changes, and what to do about methods with side effects.

Cartographer does not guess relations from naming conventions or parse them out of source. It builds the relation object and asks it for its keys.

public function author(): BelongsTo
{
    return $this->belongsTo(User::class, 'author_id');
}

That produces an edge from posts to users labelled author, with author_id as the foreign key, because the BelongsTo object reports exactly that. A custom foreign key, a custom pivot table, a non-standard morph name — all of them come out right, because Eloquent is the one answering.

What gets invoked

For each model, Cartographer collects the methods that are all of:

  • public,
  • non-static,
  • declared on the model class itself,
  • and take no required parameters.

Each one is called on a fresh instance inside a try. A method that throws is skipped and the rest continue. A return value that is not a Relation is discarded.

Declared on the class itself is the constraint worth remembering. Methods inherited from a parent class are not inspected, so a relation defined on an abstract BaseModel and shared by its subclasses produces no edges. Relations brought in through a trait are inspected, because PHP reports the composing class as the declaring class.

Building a relation runs no query

$this->belongsTo(User::class) constructs a query builder; it does not execute one. The package never calls get(), first(), or count() on a relation, and the test suite asserts that detecting relations across the fixture models produces an empty query log.

What does happen is that your method bodies run. A relation method that only returns a relation is harmless. A public zero-argument method that writes a file, hits an API, or mutates static state will do that during generation.

Two ways out:

// config/cartographer.php

// Leave the model out entirely.
'exclude_models' => [
    App\Models\Telemetry::class,
],

// Or only invoke methods that declare a Relation return type.
'relations' => [
    'strict_types_only' => true,
],

Strict mode

With strict_types_only enabled, a method is only invoked when its declared return type is a Relation subclass. Union types qualify when every member does, which covers a method that returns BelongsTo|HasOne.

// Invoked in strict mode.
public function author(): BelongsTo { ... }
public function alternate(): BelongsTo|HasOne { ... }

// Not invoked in strict mode.
public function untypedAuthor() { ... }
public function flush(): void { ... }

Strict mode is off by default so untyped codebases work without changes. Turn it on when your relation methods are typed — it removes the side-effect problem entirely, and it makes generation cheaper by skipping every other method on the model.

The trade-off is silent omission: an untyped relation method disappears from the diagram with no warning. If you enable strict mode, type every relation method.

Recognised relation types

Eloquent methodType nameWhat is read from the relation
belongsTobelongs_toForeign key, owner key
hasOnehas_oneForeign key, local key
hasManyhas_manyForeign key, local key
belongsToManybelongs_to_manyPivot table, both pivot keys, parent key
morphTomorph_toForeign key, owner key, morph name
morphOnemorph_oneForeign key, local key, morph name
morphManymorph_manyForeign key, local key, morph name
morphToManymorph_to_manyPivot table, both pivot keys, parent key, morph name
morphedByManymorphed_by_manyPivot table, both pivot keys, parent key, morph name
hasOneThroughhas_one_throughForeign key, local key, intermediate model
hasManyThroughhas_many_throughForeign key, local key, intermediate model

The type name is what --exclude-relations and relations.exclude match against. Any other Relation subclass — a custom relation of your own — is recorded under its class basename and drawn with the default one-to-many cardinality.

The morph name is derived from the morph type column: commentable_type becomes commentable. It is what appears in the edge label.

Polymorphic relations need both sides

A morphTo has no single related model, so there is nothing to point an edge at. Cartographer resolves the targets by looking for the inverse: every discovered model that declares a morphOne or morphMany back to this model with the same morph name becomes an edge target.

// Attachment
public function attachable(): MorphTo { return $this->morphTo(); }

// Post
public function attachments(): MorphMany { return $this->morphMany(Attachment::class, 'attachable'); }

// Comment
public function attachments(): MorphMany { return $this->morphMany(Attachment::class, 'attachable'); }
attachments }o--|| comments : "attachable (morph)"
attachments }o--|| posts : "attachable (morph)"
comments ||--o{ attachments : "attachable (morph)"
posts ||--o{ attachments : "attachable (morph)"

A morphTo whose inverse is never declared produces no edges at all. That is not a bug report waiting to happen — there is genuinely nothing in the codebase saying which models the relation can point at.

One subtlety follows from this. Excluding morph_many removes the morphMany edges but not the morph_to edges, because target resolution reads the inverse relations before exclusions are applied. Exclude morph to remove the whole family.

Relations that produce no edge

An edge needs both ends to be entities. A relation is dropped when:

  • The related model was not discovered — it lives outside paths, or it is in exclude_models.
  • The related model's table does not exist on the connection.
  • Its type is excluded by --exclude-relations or relations.exclude.

None of these warn. A missing edge between two models that are both in the diagram usually means the relation method threw; a missing edge to a model that is not in the diagram means the model was never discovered.