Skip to main content

Relationships

Introduction

Relationships are declared as methods on a model; each method returns a query for the related model. Load them eagerly with with(), or lazily by reading the method name as a property.

Key convention

Reversed from Laravel

$foreignKey is the column on the related model's table; $localKey is the column on the calling model's table. This is the opposite of Laravel's naming, so pass both arguments explicitly.

Every relation method takes the same first three arguments:

relation($model, $foreignKey = null, $localKey = null)

Omitted keys fall back to the package convention: $foreignKey defaults to the caller's {table}_{primaryKey} (e.g. contacts_id; plural, unlike Laravel's singular default), and $localKey defaults to the caller's primary key.

One to one

hasOne() is a direct alias of belongsTo(); both set the same oneToOne relation. Direction is decided by the keys you pass and which model calls the method:

class Contact extends Model
{
public function profile()
{
// profiles.contact_id references contacts.id
return $this->hasOne(Profile::class, 'contact_id', 'id');
}
}

class Deal extends Model
{
public function contact()
{
// deals.contact_id references contacts.id
return $this->belongsTo(Contact::class, 'id', 'contact_id');
}
}

A oneToOne relation resolves to a single model, or [] when there is no match.

One to many

class Contact extends Model
{
public function deals()
{
// deals.contact_id references contacts.id
return $this->hasMany(Deal::class, 'contact_id', 'id');
}
}

hasMany() resolves to a Collection.

Many to many

belongsToMany(
$model,
$pivotTable = null, // unprefixed pivot table name
$foreignPivotKey = null, // parent's key column ON the pivot table
$relatedPivotKey = null, // related's key column ON the pivot table
$parentKey = null, // key column on the parent table
$relatedKey = null // key column on the related table
)
class Member extends Model
{
protected $table = 'members';

public function roles()
{
// pivot table role_user(member_id, role_id)
return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id');
}

public function rolesWithAssignment()
{
return $this->belongsToMany(Role::class, 'role_user', 'member_id', 'role_id')
->withPivot(['assigned_at']);
}
}

The pivot table name is unprefixed; the package prefixes it the way join() does. Omitted keys default to {parentTable}_{primaryKey} / {relatedTable}_{primaryKey} on the pivot, and to each model's primary key otherwise.

Pivot values ride along on each related model as flat pivot_* attributes: the link key as pivot_<foreignPivotKey>, each withPivot() column as pivot_<column>:

foreach (Member::query()->with('roles')->get() as $member) {
foreach ($member->roles as $role) {
echo $role->pivot_member_id;
echo $role->pivot_assigned_at; // when declared via withPivot()
}
}
Pivot relations are read-only

Passing $pivotTable = null keeps the legacy behavior: the relation resolves exactly like hasMany() and the related table must carry the parent foreign key. Real pivot relations support reads only: no attach/detach/sync, and withCount() / whereHas() / relation aggregates over a pivot relation throw RuntimeException. See Limitations.

Eager loading

Load a relation for every parent in one extra query, avoiding N+1:

Contact::query()->with('deals')->get();
Contact::query()->with(['deals', 'profile'])->get();

// Constrain the eager-loaded relation
Contact::query()->with('deals', function ($q) {
$q->where('status', 'open');
})->get();

// Alias the loaded relation key on the result model
Contact::query()->with('deals as open_deals')->get();
foreach (Contact::query()->with('deals')->get() as $contact) {
foreach ($contact->deals as $deal) {
echo $deal->amount;
}
}

A parent with no related rows resolves to an empty result without firing a fresh query when the relation is accessed later.

Lazy loading

Read the relation method name as a property to load it on demand:

$contact = Contact::query()->find(1);

$contact->deals; // Collection, queried on first access
$contact->profile; // single model, or []

Relation aggregates and existence

Contact::query()->withCount('deals')->get(); // adds deals_count
Contact::query()->withSum('deals.amount')->get(); // adds deals_sum
Contact::query()->withAvg('deals.amount')->get(); // adds deals_avg
Contact::query()->withMin('deals.amount')->get(); // adds deals_min
Contact::query()->withMax('deals.amount')->get(); // adds deals_max
Contact::query()->withExists('deals')->get(); // adds bool deals_exists

Columns are aliased <relation>_<function>. Pass 'relation as alias' for a custom name:

Contact::query()->withCount('deals as total_deals')->get(); // adds total_deals
Contact::query()->withSum('deals.amount as revenue')->get(); // adds revenue

withMin(), withMax(), withAvg() and withSum() need the column as 'relation.column'. Passing a bare relation name aggregates *, which is only meaningful for withCount() and withExists().

withAggregate() is the generic form the five helpers above delegate to; use it for any other aggregate function:

Contact::query()->withAggregate('deals.amount', 'amount', 'STDDEV')->get();

The function name must be a bare identifier; anything else throws RuntimeException.

Filter parents by relation existence:

Contact::query()->whereHas('deals')->get();
Contact::query()->whereHas('deals', fn ($q) => $q->where('status', 'open'))->get();

// Filter by existence AND eager-load the same relation
Contact::query()->withWhereHas('deals', fn ($q) => $q->where('status', 'open'))->get();

Limitations

  • Relation names must be trusted, code-defined values. with(), whereHas() and the with* aggregates resolve a relation by calling the model method of that name. An unknown name throws RuntimeException, and framework Model methods are rejected without being called, but one of your own no-arg methods would be invoked once before its non-relation return is discarded. Never pass a request value.
  • belongsTo() and hasOne() are the same method, and the key argument order is reversed from Laravel. Always pass both keys.
  • Pivot belongsToMany is read-only and single-key: no write side, no composite keys, no DISTINCT (duplicate pivot rows yield duplicate models), and eager constraint closures may add where / orderBy / limit but cannot narrow the selected columns.
  • pivot_* attribute names are reserved. A related column literally named pivot_* is overwritten. These attributes appear in toArray() and are excluded from dirty tracking, so re-saving a hydrated related model is safe.
  • No hasManyThrough, morphTo, or other polymorphic relations.
Internal API

newBelongsTo(), newHasMany(), newBelongsToMany(), newBelongsToManyPivot(), addPivotColumns(), addRelation(), getRelations(), getRelationalKeys(), getActiveRelationKey(), prepareRelation() and prepareRelationName() are public so the builder can drive relation loading. Declare relations with hasOne() / hasMany() / belongsTo() / belongsToMany() and load them with with(); the rest is internal.