Eloquent covers the day-to-day relationship surface -
declaration syntax, the option table, basic per-kind chaining. This
chapter is the relationship-specific deep dive: how a user.posts()
call actually resolves to SQL, how the eager loader avoids N+1, how
the existence engine (has / where_has / where_belongs_to) renders
correlated EXISTS subqueries, how polymorphism survives Rust's lack
of late static binding, and what falls out of the type system when
all eleven relation kinds have to coexist on one trait.
If you're new to Eloquent on Suprnova, read
Eloquent first - that page teaches the
declaration syntax. This page assumes you have a model with a
relations = { ... } block already and want to understand what's
underneath.
The eleven relation kinds
Every relation kind in RelationKind is one of:
| Kind | Side | Cardinality | Across families | Pivot |
|---|---|---|---|---|
HasOne<R> |
parent | one | no | - |
HasMany<R> |
parent | many | no | - |
BelongsTo<R> |
child | one | no | - |
BelongsToMany<R, P> |
either | many | no | yes |
HasOneThrough<B, R> |
parent | one | no | - |
HasManyThrough<B, R> |
parent | many | no | - |
MorphOne<R> |
parent | one | yes | - |
MorphMany<R> |
parent | many | yes | - |
MorphTo |
child | one | yes (n targets) | - |
MorphToMany<R, P> |
parent | many | yes | yes |
MorphedByMany<R, P> |
m2m partner | many | yes (inverse) | yes |
"Across families" means the related row's type varies - a Comment
might belong to a Post or a Video, not just one fixed parent table.
That's polymorphism, and Suprnova handles it via the morph
registry plus a per-family enum.
What the macro emits
When you write:
use model;
#[suprnova::model] expands into five things for posts:
- Relation method -
fn posts(&self) -> HasMany<Self, Post>. Returns a lazy wrapper carryingself.idplus FK metadata; no SQL runs yet. - Loaded-accessor -
fn posts_loaded(&self) -> &[Post]. Reads from the eager cache afterUser::with(["posts"]). Empty slice when no eager load ran. - Count-accessor -
fn posts_count(&self) -> u64. Reads from the same cache afterUser::with_count(["posts"]). - Dispatcher arm - match arm in the model's
__eager_loadinherent method. The eager loader looks up"posts"and runs theIN-query. - Inventory entry - one
inventory::submit!(RelationEntry { ... })so the relation is enumerable at runtime (admin tooling, the existence engine, the morph dispatcher all walk this).
You never see (4) or (5). They power the rest of this chapter.
Lazy resolution: how user.posts() becomes SQL
user.posts() returns a HasMany<User, Post> wrapper, not a query
result. The wrapper holds the parent's PK value plus the FK column
name, and a pre-filtered Builder<Post> with
WHERE posts.user_id = ? already applied. Nothing has touched the
database yet.
use Direction;
// No SQL.
let posts_q = user.posts;
// SQL: SELECT * FROM posts WHERE user_id = ? ORDER BY id DESC LIMIT 5
let recent = user.posts
.order_by
.limit
.get
.await?;
// SQL: SELECT COUNT(*) FROM posts WHERE user_id = ?
let n = user.posts.count.await?;
The dual-API surface (Eloquent → Naming note)
is honoured on the wrapper: both .filter("col", v) and
.db_where("col", v) work, identically. The chainable surface on
HasOne / HasMany / MorphOne / MorphMany covers filter /
db_where / order_by / latest / oldest / limit / take.
Through and morph m2m relations expose only their terminal methods -
they go through hand-written SQL stitches, not a Builder<R>, so
they can't compose with the standard chain. See Through
relations and Polymorphic
m2m below.
Soft deletes follow through
When the related type implements SoftDeletes,
the relation wrapper inherits its global scope. user.posts().get()
hides trashed posts the same way Post::query().get() does. Three
forwarders punch through:
let alive = user.posts.get.await?; // default: alive only
let all = user.posts.with_trashed.get.await?; // alive + trashed
let dead = user.posts.only_trashed.get.await?; // trashed only
with_trashed / only_trashed exist on HasOne, HasMany,
MorphOne, MorphMany, BelongsToMany, MorphToMany,
MorphedByMany, and BelongsTo. HasOneThrough and HasManyThrough
do not have them: they always leave trashed rows out, as
Through soft-deletes below describes.
One-to-one: HasOne and BelongsTo
HasOne is the parent saying "this child has a column pointing at me".
BelongsTo is the child saying "I have a column pointing at the
parent". Both run a single WHERE fk = ? LIMIT 1 and return
Option<R>.
// HasOne - parent → child
let profile: = user.profile.first.await?;
// BelongsTo - child → parent
let owner: = profile.user.first.await?;
BelongsTo adds one Laravel-shaped affordance the others don't need:
with_default. When the child's FK is null OR the parent row was
deleted, first() returns the closure's stand-in rather than None:
// Always returns Some(User) - either the real author or the Guest stub.
let display: = comment.author.first.await?;
The eager-load dispatcher honours the same fallback - lazy and eager
paths share the default behaviour, so template code that prints
comment.author_loaded()[0].name doesn't have to branch.
One-to-many: HasMany
HasMany is the parent-side many-cardinality relation. The terminal
.get() returns a Collection<R> - the
Laravel-shaped wrapper around Vec<R> - so the model-aware surface
composes:
let titles = user.posts
.order_by
.limit
.get
.await?
.;
latest() and oldest() are sugar for
order_by("created_at", Direction::Desc) and Asc respectively -
they only resolve against models that declare a created_at column,
which the #[suprnova::model] macro auto-adds whenever timestamps are
on (the default).
Many-to-many: BelongsToMany<R, P> and the first-class pivot
BelongsToMany is many-to-many through a join table. Suprnova's pivot
is itself a #[suprnova::model] struct with its own migrations, its
own accessors, its own events. That's the divergence - see below.
Mutators run against the pivot row:
use attrs;
user.roles.attach.await?;
user.roles.attach_with.await?;
user.roles.detach.await?;
user.roles.sync.await?;
sync reads the current pivot set, computes
attach_set = ids - current and detach_set = current - ids, and
runs the deltas inside a transaction. Duplicates in the input set
collapse by their JSON-string form so sync([1, 1, 2]) does what you
mean.
Reading goes through the two-query strategy:
// Query 1: SELECT roles.*, role_user.* via INNER JOIN, scoped by user_id.
// Query 2: SELECT role_user.* for the same join, to stamp __pivot per row.
let roles = user.roles.get.await?;
// Each role carries the pivot context the macro made accessible:
for r in &roles
Filtering on pivot columns
where_pivot and its family constrain the pivot table, not the
related table. Reach for them when the join row carries state you want
to filter on - an active flag, an expiry timestamp, a scope column.
The examples below assume the RoleUser pivot above also declares
active, pinned and note columns:
// Roles whose pivot row is still active.
let active = user.roles.where_pivot.get.await?;
// Roles assigned in a window, or explicitly pinned.
let visible = user
.roles
.where_pivot_between
.or_where_pivot
.get
.await?;
// A nested group: (active = 1 AND note IS NOT NULL) OR pinned = 1.
let complex = user
.roles
.where_pivot_group
.or_where_pivot
.get
.await?;
The full family:
| Method | SQL |
|---|---|
where_pivot(col, val) |
col = ? |
where_pivot_op(col, op, val) |
col <op> ? |
where_pivot_in(col, vals) |
col IN (...) |
where_pivot_not_in(col, vals) |
col NOT IN (...) |
where_pivot_null(col) |
col IS NULL |
where_pivot_not_null(col) |
col IS NOT NULL |
where_pivot_between(col, low..=high) |
col BETWEEN ? AND ? |
where_pivot_not_between(col, low..=high) |
col NOT BETWEEN ? AND ? |
where_pivot_group(|q| ...) |
(... AND ...) |
Every method has an or_ twin that folds into a disjunction with the
term before it, the same way or_where does on Builder. A closure
group stays atomic inside that disjunction, so
.where_pivot_null("note").or_where_pivot_group(|q| ...) reads as
note IS NULL OR (...) and not as a flattened chain.
Column names interpolate into the pivot statement as raw SQL
identifiers, the same contract as Builder::filter. Never take one
from request data. The values bind as parameters, so those are safe to
take from request data.
The closure form runs on the same statement, so a where_raw or a
where_has inside it lands in the pivot SQL verbatim - the identifier
allowlist skips the raw escape hatch by design. Treat the closure the
way you treat Builder::where_raw: never build its fragments from
untrusted input.
The same family is on MorphToMany and MorphedByMany.
Why Suprnova diverges: pivot filters are read-only
Two boundaries, both deliberate.
A pivot filter never narrows a write. Laravel folds wherePivot
constraints into detach(), so ->wherePivot('active', 1)->detach()
deletes only the active join rows. Suprnova builds its pivot DELETE
by hand, and a read predicate that silently does or does not reach a
delete is a difference you cannot see from the call site. So attach,
attach_with, detach and sync return an error while any filter is
set. Split the two intents:
// Read what matches, then act on it explicitly.
let stale = user.roles.where_pivot.get.await?;
for role in &stale
Eager loading does not carry pivot filters. user_query.with(["roles"])
runs through the relation's generated eager-load path, which scans the
pivot table for the whole parent batch at once and applies a
with_where closure to the related table. There is no slot on that
path for a pivot predicate. When you need a filtered many-to-many read,
call the relation accessor per parent
(user.roles().where_pivot(...).get()) instead of eager loading.
Why Suprnova diverges: pivot is a real model
Laravel's pivot is an opaque per-attribute bag ($role->pivot->note).
Suprnova requires you to declare the pivot struct because Rust's type
system needs the columns at compile time - and once you've paid for
that declaration, the pivot gets the same #[suprnova::model]
treatment as any other table: migrations, events, observers,
factories, soft-delete. r.pivot::<RoleUser>() returns a typed
reference; no string-keyed attribute lookups, no surprises at runtime
when a column is misspelled.
The cost is one extra struct per pivot table. The benefit is that the pivot can carry behaviour - domain logic, validation rules, audit columns - without escaping into raw SQL.
HasOneThrough and HasManyThrough
Two-hop relations: A → B → C where B is an intermediate model whose
FK points at A, and C is the final target whose FK points at B.
Classic example: Country has many Users; User has many Posts;
Country::posts() jumps both hops in one SQL round trip.
// Single INNER JOIN: SELECT posts.* FROM posts
// INNER JOIN users ON posts.user_id = users.id
// WHERE users.country_id = ?
let posts: = country.posts.get.await?;
HasOneThrough has the same shape but .get() returns
Option<C> (matching the one-cardinality semantics) and .first() is
its alias.
Through wrappers expose only their terminals - get / first / count
plus the key setters (first_key / second_key / local_key /
second_local_key). They do not flow through a Builder<C>, so they
can't chain .filter(...) or .order_by(...). If you need to filter
across the join, fall back to two explicit relation hops.
Through soft-deletes
Through relations filter both the intermediate and the target by their
soft-delete column when those models declare #[model(soft_deletes)],
matching Laravel's hasManyThrough: trashed rows on either side stay
out of the join.
To include trashed rows, query the two hops yourself: load the
intermediate models through their own relation, then query the target
model by their keys, and add with_trashed() on the side whose trashed
rows belong in the result.
Polymorphic relations
A polymorphic FK is a column pair: <name>_id (the row's primary key)
plus <name>_type (a string identifying which table the id lives
in). One Comment row can point at a Post or a Video without
adding either a post_id or video_id column.
Suprnova ships four polymorphic kinds: MorphOne, MorphMany,
MorphTo, and the m2m pair MorphToMany / MorphedByMany. They all
share one piece of infrastructure: the morph registry.
MorphOne<R> and MorphMany<R> - parent side
MorphOne and MorphMany mirror HasOne and HasMany but layer the
<name>_type discriminator on top. The inner builder is pre-filtered
with WHERE <name>_id = ? AND <name>_type = ?, so polymorphic
children pointing at other families never appear in the result.
let post_comments = post.comments.get.await?; // only commentable_type = 'post'
let video_comments = video.comments.get.await?; // only commentable_type = 'video'
morph_type = "post" is the string the parent registers in the
child's commentable_type column. Default is the snake-cased struct
name, but overriding is the right move for any model you're shipping -
table-renaming refactors shouldn't break the polymorphic key.
MorphTo and the per-family enum
MorphTo lives on the morph-table side. The user declares the
targets list up front:
The macro emits a per-family enum at the declaration site:
// Emitted by the macro - you don't write this.
And comment.commentable() returns a fetch helper whose .get()
resolves to the enum:
match comment.commentable.get.await?
Why Suprnova diverges: per-family enum
Laravel's morphTo returns mixed - PHP's dynamic dispatch resolves
the method at runtime. Rust has no late static binding, so Suprnova
makes the family explicit. The benefits beat the typing cost:
- Exhaustive
match- the compiler tells you when a new morph target lands and you forgot to handle it. Unknown(String, id)is type-safe - orphaned rows from a removed parent model class are surfaced as a variant, not panicked on.- The targets list documents the schema - reading the
MorphTodeclaration tells you every type that can sit on the other end. No database query required to enumerate them.
MorphTo keys
A MorphTo target can use any primary key type the model declares:
i64, String, UUID or ULID. The morph table's <name>_id column takes
the same type as its targets' keys.
Three rules hold:
- All targets of one
MorphTorelation have one key type. A relation that mixes them does not compile. - The child's
<name>_idfield has that key type. A field of another type does not compile. - The parent side is not checked.
MorphOne,MorphMany,MorphToManyandMorphedByManyread the parent's own key, and the framework does not compare it with the child's<name>_idfield. If several parents own the same child, keep<name>_idat the key type of every one of them.
The id in the per-family enum's Unknown variant, and the
morph_id field of MorphTo, are a serde_json::Value: the key as
JSON. Read it with as_i64() or as_str().
The lazy comment.commentable().get() finds the target by its key and
applies no global scope of the target. The eager
with(["commentable"]) runs the query of the target and applies its
global scopes, so a target that a scope hides comes back as Unknown.
A nested path goes through MorphTo: with(["commentable.user"]) loads
user on the targets, one query for each target type that is present.
Every target of the relation must declare user. A target that does not
is an error that names the type and the relation, even when no row of
that type is loaded. See
Eloquent - Polymorphic keys and nested loads.
MorphToMany and MorphedByMany
Polymorphic many-to-many through a single pivot. One side is
"morphable" (Post.tags(), Video.tags() - both go through the same
taggables pivot). The other is the shared m2m partner (Tag.posts(),
Tag.videos() - same pivot, scanned the other way).
MorphToMany is the mutating side - attach / attach_with / detach
/ sync all live there. MorphedByMany is read-only: each tag.posts()
call returns only Post-typed taggables, each tag.videos() returns
only Video-typed taggables, no mixing in one collection.
Mutate from the morphable side:
post.tags.attach.await?;
post.tags.sync.await?;
Read from either:
let tags_on_post: = post.tags.get.await?;
let posts_with_rust_tag: = rust_tag.posts.get.await?;
The morph registry
Every struct annotated #[suprnova::model(morph_type = "...")] emits
one MorphTypeEntry via inventory::submit! at compile
time. The registry powers three things:
- Per-family enum dispatch -
MorphTo.get()reads the child row's<name>_typestring and looks it up to find the right enum variant. MorphedByManytarget filtering -target_morph_type = "post"resolves through the registry to ensure the type string is real.- Sanity checks -
find_morph_type("post")returnsNoneif no model has registered with that string, distinguishing "deliberately unregistered" from "typo".
use ;
use TypeId;
for entry in morph_types
if let Some = find_morph_type
let by_id = find_morph_type_by_id;
Models without a morph_type = "..." attribute deliberately don't
register - the registry is opt-in. A non-polymorphic User model
contributes nothing to it, which is what makes
find_morph_type("user") returning None a useful signal.
Querying by relation existence
has / where_has / doesnt_have / where_relation /
where_belongs_to form Suprnova's relation-existence engine. They all
render as correlated EXISTS (...) subqueries against the parent's
own SELECT - no JOIN, no duplicate parent rows, no GROUP BY.
// Users with at least one post.
let with_posts = query.has.get.await?;
// Users with at least three posts.
let prolific = query.has_count.get.await?;
// Users with at least one PUBLISHED post.
let published_authors = query
.
.get
.await?;
// Users with NO posts.
let empty_users = query.doesnt_have.get.await?;
// Users with no DRAFT posts (they may still have published ones).
let clean = query
.
.get
.await?;
// Shortcut: where_has + single column == match.
let same = query
.where_relation
.get
.await?;
// where_belongs_to - direct FK = ? on THIS table (no EXISTS needed,
// since the FK is on the child row).
let mine = query
.where_belongs_to
.get
.await?;
How it works
The engine walks the relation inventory at query-build time. For each
named relation, it pulls the RelationEntry and renders the
appropriate SQL shape per kind:
HasOne/HasMany/MorphOne/MorphMany→EXISTS (SELECT 1 FROM child WHERE child.<fk> = parent.<pk>). Morph kinds addAND child.<name>_type = '<parent_morph_type>'.BelongsTo→EXISTS (SELECT 1 FROM parent WHERE parent.<pk> = child.<fk>).BelongsToMany/MorphToMany→ joins through the pivot:EXISTS (SELECT 1 FROM pivot WHERE pivot.<parent_fk> = parent.<pk> ...).- Through relations → joins through the intermediate.
The closure form (where_has::<R, _>(rel, |q| ...)) constructs an
inner Builder<R>; whatever WHERE terms that builder produces land
inside the subquery's body. Placeholder numbering is monotonic across
the whole statement, so the engine works correctly with $1-style
Postgres parameters.
where_belongs_to is the one exception that doesn't render an
EXISTS. The belongs-to FK lives on the parent's own row, so a
direct WHERE child.<fk> = ? is exactly the right SQL - no subquery
needed. If the relation name is unknown to the parent's inventory,
the engine emits WHERE 1 = 0 so the query safely returns nothing.
Why this beats LEFT JOIN
Laravel's older has / whereHas engine used to emit JOINs and
duplicate parent rows; the correlated EXISTS rewrite landed in Laravel 9.
Suprnova ships EXISTS from day one. The advantages: no duplicates
in the result set, no GROUP BY workarounds for aggregates, no need
for DISTINCT, and the database's optimiser sees a real subquery
instead of a JOIN it can't push predicates through. For
has_count(rel, ">=", n) the engine renders
(SELECT COUNT(*) FROM child WHERE ...) >= n directly - one query, one
plan.
Eager loading - with, with_count, with_* aggregates
The lazy user.posts().get() does one query per parent. That's N+1
when you have many users:
// Bad: 1 query for users + 100 queries for posts.
let users = query.limit.get.await?;
for u in &users
prevent_lazy_loading(true) turns that loop into an error, so you find
it in development. See
Eloquent - Preventing lazy loading.
with(["posts"]) collapses that to two queries total - regardless of
the parent count:
// Good: 1 query for users + 1 IN-query for all posts.
let users = query
.with
.limit
.get
.await?;
for u in &users
Nested paths work too - dot-separated relation names recurse:
let users = query
.with
.get
.await?;
// 4 queries: users, posts IN users.id, comments IN posts.id, authors IN comments.user_id.
with_count and aggregates
with_count adds a per-relation COUNT(*) GROUP BY parent_fk aggregate
loaded alongside the parents - one extra query per relation:
let users = query.with_count.get.await?;
for u in &users
Four aggregate variants stack: with_sum, with_avg, with_min,
with_max. The cache key shape is <rel>_<kind>_<col> so stacking
multiple aggregates on the same relation doesn't collide:
let users = query
.with_count
.with_sum
.with_avg
.get
.await?;
for u in &users
See Eloquent → Eager loading → Cache layout for the full storage contract.
Constrained eager loads - with_where
with_where filters which child rows land in the eager cache without
losing parents that have no matching children:
use Builder;
let users = query
.with_where
.get
.await?;
// Each u.posts_loaded() contains only published posts.
// Users with zero published posts still appear in the result set -
// their posts_loaded() returns an empty slice.
with_where differs from where_has in intent: where_has filters
the parent set ("users who have at least one published post");
with_where filters the eager cache ("for all users, load only their
published posts"). Use both together when you want both effects.
The predicate is an Fn, not an FnOnce, so a builder carrying one can
be cloned and run more than once. A closure that wants to consume a
captured value should clone it inside:
let wanted = vec!;
let users = query
// `wanted.clone()` inside, not `move` of `wanted` itself - the
// closure may run once per clone of the builder.
.with_where
.get
.await?;
Cloning a query keeps its eager-load plan
Builder is Clone, and the clone carries the eager-load plan with it,
so the "build a base query, derive several from it" pattern works:
let base = query.with.filter;
let first_page = base.clone.limit.get.await?;
let total = base.count.await?;
// first_page rows have posts_loaded() populated.
Why Suprnova diverges
Laravel's $query->with(...) clones freely because PHP arrays copy on
assignment. Rust has to say what a clone means for a type-erased
closure, and through v0.7.2 Suprnova answered by dropping the plan -
the clone succeeded, the query succeeded, and the relations were simply
absent. Sharing the predicate through an Arc makes the clone total,
at the cost of the Fn bound above.
Eager loading inside chunk / chunk_by_id / lazy remains a loud
error rather than a silent per-chunk N+1. Re-apply .with(...) inside
the per-chunk closure when you want it.
Loading on already-fetched collections
When you fetch a Collection<M> without an eager-load plan, you can
attach one after the fact:
let mut users = query.get.await?;
users.load.await?; // unconditional
users.load_missing.await?; // skip what's already loaded
load_missing walks each parent's __eager cache and only fires the
IN-query for rows that haven't already loaded the relation. Useful in
loops where some parents got eager-loaded earlier in the request and
others didn't.
Opting out - without
without removes named relations from the eager plan, useful when a
base scope adds defaults you don't want for this call:
let users = query
.with
.without // drops team from the plan
.get
.await?;
Touching owners
A child can declare that writing it should freshen its owner's
updated_at:
BelongsTo and MorphTo relations can be touched - the touched row has
to be identifiable from columns on the child, which is exactly what the
owning side gives you. The framework resolves the owner through the
relation registry, so the touch costs one UPDATE and no SELECT.
For a MorphTo name (touches = ["commentable"]), the owner is the row
that <name>_type and <name>_id point at. The framework finds it
after the pre-write listeners have run and before the statement. A
<name>_type that names none of the targets makes the write fail with
nothing written.
Owners that disclaim timestamps (#[model(timestamps = false)]), are
reached through a NULL foreign key (or a NULL <name>_id), or are
soft-deleted are skipped silently. Suppress the cascade for a block of work with
without_touching (all owners) or without_touching_on::<Post, _, _>
(one type). Full semantics in
Eloquent - Parent touching.
The escape hatch
When a relation doesn't fit any of the eleven kinds - recursive trees, polymorphic-through-non-id keys, three-way pivots, anything bespoke - hand-write the method. The macro doesn't prevent it; you just don't get the loaded-accessor or the eager-load dispatcher arm for that relation.
The trade-off is explicit: hand-written methods don't appear in the
relations() inventory, the existence engine doesn't know about them,
and the eager loader can't include them in a plan. For one-offs that's
fine. For anything you'd want to with(["..."]), declare it as a
proper relation kind even if you have to use the macro options to bend
it into shape.
Next
- Eloquent - the day-to-day model surface; relation declaration syntax lives there.
- Database - connections, transactions, multi-driver, the lower layer everything sits on.
- Migrations - the schema side of the FK columns these relations need to exist.
- Query Builder - the dual-API surface that relation wrappers forward into.
- Eloquent Resources - turning loaded relations into JSON:API payloads for the wire.
