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
$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()
}
}
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 thewith*aggregates resolve a relation by calling the model method of that name. An unknown name throwsRuntimeException, and frameworkModelmethods 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()andhasOne()are the same method, and the key argument order is reversed from Laravel. Always pass both keys.- Pivot
belongsToManyis read-only and single-key: no write side, no composite keys, noDISTINCT(duplicate pivot rows yield duplicate models), and eager constraint closures may addwhere/orderBy/limitbut cannot narrow the selected columns. pivot_*attribute names are reserved. A related column literally namedpivot_*is overwritten. These attributes appear intoArray()and are excluded from dirty tracking, so re-saving a hydrated related model is safe.- No
hasManyThrough,morphTo, or other polymorphic relations.
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.