Manual contentsEloquent ORMBrowse 126 chapters
Manual 35 min read

Eloquent Relationships

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 suprnova::model;

#[model(table = "users", relations = {
    posts: HasMany<Post>,
})]
pub struct User {
    pub id: i64,
    pub name: String,
}

#[suprnova::model] expands into five things for posts:

  1. Relation method - fn posts(&self) -> HasMany<Self, Post>. Returns a lazy wrapper carrying self.id plus FK metadata; no SQL runs yet.
  2. Loaded-accessor - fn posts_loaded(&self) -> &[Post]. Reads from the eager cache after User::with(["posts"]). Empty slice when no eager load ran.
  3. Count-accessor - fn posts_count(&self) -> u64. Reads from the same cache after User::with_count(["posts"]).
  4. Dispatcher arm - match arm in the model's __eager_load inherent method. The eager loader looks up "posts" and runs the IN-query.
  5. 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 suprnova::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("id", Direction::Desc)
    .limit(5)
    .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: Option<Profile> = user.profile().first().await?;

// BelongsTo - child → parent
let owner: Option<User> = 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:

#[model(table = "comments", relations = {
    author: BelongsTo<User> {
        with_default = || User { id: 0, name: "Guest".into(), .. },
    },
})]
pub struct Comment { /* ... */ }

// Always returns Some(User) - either the real author or the Guest stub.
let display: Option<User> = 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("created_at", Direction::Desc)
    .limit(10)
    .get()
    .await?
    .pluck::<String>("title");

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).

You call first_or_create(attributes, extra) on HasOne, HasMany or BelongsToMany to return a matching record or create it. You supply lookup columns in attributes and creation values in extra. You keep an existing record's values. Your relation sets its foreign key after applying the related model's mass-assignment guard, including a custom local key. You also attach a pivot row through a many-to-many relation. You attach an existing record when it matches outside the relation.

let post = user.posts()
    .first_or_create(attrs! { title: "Draft" }, attrs! { views: 0 })
    .await?;
let role = user.roles()
    .first_or_create(attrs! { name: "editor" }, attrs! {})
    .await?;

You call increment_or_create(attributes, column, default, step, extra) on the same relation kinds to create a counter or increment it. You use default for a new record. You add step to an existing record, including a zero or negative step. You supply integer amounts. You apply extra to an existing record in the same database update as the increment. You receive the record with its stored counter value.

let post = user.posts()
    .increment_or_create(attrs! { title: "Draft" }, "views", 1, 3, attrs! {})
    .await?;

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.

#[model(table = "users", relations = {
    roles: BelongsToMany<Role, RoleUser> {
        with_pivot = ["assigned_at"],
        with_timestamps,
    },
})]
pub struct User { /* ... */ }

#[model(table = "role_user", primary_key = "id")]
pub struct RoleUser {
    pub id: i64,
    pub user_id: i64,
    pub role_id: i64,
    pub assigned_at: Option<chrono::DateTime<chrono::Utc>>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

Mutators run against the pivot row:

use suprnova::attrs;

user.roles().attach(role.id).await?;
user.roles().attach_with(role.id, attrs! { assigned_at: now }).await?;
user.roles().detach(role.id).await?;
user.roles().sync([role_a.id, role_b.id, role_c.id]).await?;

Extra pivot columns go through the pivot model's casts and mutators, as Model::create does: a column the pivot declares AsEncrypted is stored encrypted, and reads back through r.pivot::<RoleUser>() as the plain value. A key the pivot model does not declare is written as it is.

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.

sync_without_detaching is the attach half of sync, Laravel's syncWithoutDetaching. It attaches each id the relation doesn't hold yet and leaves every other pivot row as it is, which suits an "add to favorites" action that must not drop the rows already there:

// The user holds roles 1 and 2.
user.roles().sync_without_detaching([2, 3]).await?;
// Now it holds 1, 2, and 3. The rows for 1 and 2 are unchanged.

An id the relation already holds is skipped, not rewritten, so its row keeps its extra pivot columns and its created_at and updated_at. The new rows get timestamps when the relation has with_timestamps. The inserts run in one transaction, so one that fails rolls back the others. You receive SyncChanges from both methods: attached, detached and updated hold JSON ids. sync_without_detaching always reports an empty detached list. You supply per-id columns with (id, Attrs) records:

let changes = user.roles()
    .sync_without_detaching([(2, attrs! { assigned_at: now })])
    .await?;
assert_eq!(changes.updated, vec![serde_json::json!(2)]);

An existing row updates only when a supplied column changes. You keep unsupplied columns, and an unchanged row keeps its timestamps. A changed row receives updated_at when you enable with_timestamps. Both methods reconcile and write inside one transaction. You add .touch_parent() when the parent timestamp must advance after a relation change. An inverse relation named in the related model's touches also enables that parent touch; without_touching suppresses it.

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 {
    let pivot = r.pivot::<RoleUser>();
    println!("{} assigned at {:?}", r.name, pivot.assigned_at);
}

You delete a loaded pivot without a surrogate id through its original foreign-key pair. You declare one key as the pivot model's primary_key and keep both keys as fields. You obtain the pivot from a lazy or eager relation read, then clone it because delete() consumes the model:

role.pivot::<RoleUser>().clone().delete().await?;

Your other attachments remain in place. A loaded morph pivot also uses its original type column, so another morph family with the same keys remains. A pivot with a surrogate id uses its ordinary model deletion path.

You call chunk_map(size, closure) on BelongsToMany, HasOneThrough or HasManyThrough to map each related record while reading bounded batches. You receive a Collection of mapped values in primary-key order. Your closure receives one record and returns an asynchronous Result. A many-to-many record gives you its pivot context. You retain target scopes, soft-delete filters and pivot filters. You receive an error for a zero size. You stop at the first database or closure error and receive that error.

let names = user.roles()
    .chunk_map(10, |role| async move { Ok(role.name) })
    .await?;

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("active", 1i64).get().await?;

// Roles assigned in a window, or explicitly pinned.
let visible = user
    .roles()
    .where_pivot_between("assigned_at", start..=end)
    .or_where_pivot("pinned", 1i64)
    .get()
    .await?;

// A nested group: (active = 1 AND note IS NOT NULL) OR pinned = 1.
let complex = user
    .roles()
    .where_pivot_group(|q| q.filter("active", 1i64).filter_not_null("note"))
    .or_where_pivot("pinned", 1i64)
    .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. Inside a closure, the builder's or_* conditions are flat and follow SQL precedence. 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.

Pivot filters on writes and eager loads

Two boundaries, both deliberate.

You use pivot filters with sync and sync_without_detaching to narrow the current set, updates and detachments. A row outside the filter stays as it is. A new record still needs the pivot columns its filter expects:

let changes = user.roles().where_pivot("active", 1i64)
    .sync([(role.id, attrs! { active: 1 })]).await?;

attach, attach_with and detach still refuse a pivot filter. Polymorphic pivot mutators also retain that refusal.

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.

A relation reads its pivot table directly, as Laravel's using(Pivot) relation does, so the pivot model's own global scopes and soft-delete filter apply when you query RoleUser::query(), not to the attachments user.roles() returns. Narrow the attachments with where_pivot.

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.

#[model(table = "countries", relations = {
    posts: HasManyThrough<User, Post>,
})]
pub struct Country { /* ... */ }

// Single INNER JOIN: SELECT posts.* FROM posts
//   INNER JOIN users ON posts.user_id = users.id
//   WHERE users.country_id = ?
let posts: Collection<Post> = country.posts().get().await?;

HasOneThrough has the same shape but .get() returns Option<C> (matching the one-cardinality semantics) and .first() is its alias.

You use the through terminals get, first, chunk_map and find_or_new, plus count on HasManyThrough and is on HasOneThrough. You configure keys with first_key, second_key, local_key and second_local_key. You cannot chain .filter(...) or .order_by(...) on these wrappers. You query two explicit relation hops when you need that chain.

Looking up a target by key

You call find_or_new(key) on either through relation to search only records your relation reaches. You receive a new unsaved instance with default field values when the key does not match. You create no database row and copy no lookup key into the new instance.

let post = country.posts().find_or_new(post_id).await?;

You call is(&model) on HasOneThrough to check whether your relation reaches that model. You ask the database once with a count query and load no target instance. You receive false for a record outside the relation or one hidden by the relation's scopes or soft-delete filters.

let matches = user.profile().is(&profile).await?;

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. The target's global scopes apply too, as they do to Post::query(); the intermediate is filtered by its soft-delete column only. Eager loads (with(["posts"])), counts and aggregates of a Through relation leave out the same rows.

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.

#[model(table = "posts", morph_type = "post", relations = {
    comments: MorphMany<Comment> { name = "commentable" },
})]
pub struct Post { /* ... */ }

#[model(table = "videos", morph_type = "video", relations = {
    comments: MorphMany<Comment> { name = "commentable" },
})]
pub struct Video { /* ... */ }

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.

You create or save a child through MorphOne or MorphMany to fill its owner id and canonical type. The relation's ownership overrides caller values, including guarded ownership columns. Your other create attributes retain the model's mass-assignment rules.

let comment = post.comments().create(attrs! { body: "Hello" }).await?;
let comment = post.comments().save(comment).await?;
let changed = post.comments().upsert(
    vec![attrs! { body: "Hello" }],
    vec!["commentable_id", "commentable_type", "body"],
    Some(vec!["body"]),
).await?;

You pass a new model to save to insert it, or a stored model to update it. You use upsert(rows, unique_by, update) to fill ownership on every row before the bulk write. You choose a database unique constraint for unique_by. Empty rows return zero. Database errors return to you.

Morph aliases

A table may hold more than one string for one model. A Laravel database stores the PHP class name, App\Models\Post, until the application adopts Relation::morphMap, and the alias, post, after it. Declare the string writes should store as morph_type, and every other string as an alias:

#[model(
    table = "posts",
    morph_type = "App\\Models\\Post",
    morph_aliases = ["post"],
    relations = {
        comments: MorphMany<Comment> { name = "commentable" },
    },
)]
pub struct Post { /* ... */ }

Reads accept every name: MorphMany and MorphOne, direct and eager, has, with_count and the aggregates match rows holding either, and a MorphTo loads the post a row names by either. Writes store the morph_type. morph_aliases needs a morph_type. MorphToMany and MorphedByMany pivots match the morph_type alone. See Running on a Laravel Database.

MorphTo and the per-family enum

MorphTo lives on the morph-table side. The user declares the targets list up front:

#[model(table = "comments", relations = {
    commentable: MorphTo { name = "commentable", targets = [Post, Video] },
})]
pub struct Comment {
    pub id: i64,
    pub commentable_id: i64,
    pub commentable_type: String,
    pub body: String,
}

The macro emits a per-family enum at the declaration site:

// Emitted by the macro - you don't write this.
pub enum CommentableMorph {
    Post(Post),
    Video(Video),
    Unknown(String, serde_json::Value), // fallback for unregistered <name>_type
}

And comment.commentable() returns a fetch helper whose .get() resolves to the enum:

match comment.commentable().get().await? {
    CommentableMorph::Post(post) => println!("on post: {}", post.title),
    CommentableMorph::Video(video) => println!("on video: {}", video.url),
    CommentableMorph::Unknown(t, id) => {
        eprintln!("orphaned commentable_type={t} id={id}");
    }
}

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 MorphTo declaration 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.

#[model(
    table = "notes",
    relations = {
        subject: MorphTo { targets = [Article, Recipe] },
    },
)]
pub struct Note {
    pub id: i64,
    pub subject_id: String,      // the key type of Article and Recipe
    pub subject_type: String,
    pub body: String,
}

Three rules hold:

  • All targets of one MorphTo relation have one key type. A relation that mixes them does not compile.
  • The child's <name>_id field has that key type. A field of another type does not compile.
  • The parent side is not checked. MorphOne, MorphMany, MorphToMany and MorphedByMany read the parent's own key, and the framework does not compare it with the child's <name>_id field. If several parents own the same child, keep <name>_id at 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).

#[model(table = "tags", relations = {
    posts: MorphedByMany<Post, Taggable> {
        name = "taggable",
        target_morph_type = "post",
    },
    videos: MorphedByMany<Video, Taggable> {
        name = "taggable",
        target_morph_type = "video",
    },
})]
pub struct Tag { /* ... */ }

#[model(table = "posts", morph_type = "post", relations = {
    tags: MorphToMany<Tag, Taggable> { name = "taggable" },
})]
pub struct Post { /* ... */ }

#[model(table = "taggables", primary_key = "id", timestamps = false)]
pub struct Taggable {
    pub id: i64,
    pub tag_id: i64,
    pub taggable_id: i64,
    pub taggable_type: String,
}

MorphToMany is the mutating side - attach / attach_with / detach / sync / sync_without_detaching 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(rust_tag.id).await?;
post.tags().sync([rust_tag.id, async_tag.id]).await?;

Read from either:

let tags_on_post: Collection<Tag> = post.tags().get().await?;
let posts_with_rust_tag: Collection<Post> = 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:

  1. Per-family enum dispatch - MorphTo.get() reads the child row's <name>_type string and looks it up to find the right enum variant.
  2. MorphedByMany target filtering - target_morph_type = "post" resolves through the registry to ensure the type string is real.
  3. Sanity checks - find_morph_type("post") returns None if no model has registered with that string, distinguishing "deliberately unregistered" from "typo".
use suprnova::{morph_types, find_morph_type, find_morph_type_by_id};
use std::any::TypeId;

for entry in morph_types() {
    println!("{} -> {}", entry.morph_type, entry.type_name);
}

if let Some(e) = find_morph_type("post") {
    assert_eq!(e.table, "posts");
}

let by_id = find_morph_type_by_id(TypeId::of::<Post>());

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 = User::query().has("posts").get().await?;

// Users with at least three posts.
let prolific = User::query().has_count("posts", ">=", 3).get().await?;

// Users with at least one PUBLISHED post.
let published_authors = User::query()
    .where_has::<Post, _>("posts", |q| q.filter("published", true))
    .get()
    .await?;

// Users with NO posts.
let empty_users = User::query().doesnt_have("posts").get().await?;

// Users with no DRAFT posts (they may still have published ones).
let clean = User::query()
    .where_doesnt_have::<Post, _>("posts", |q| q.filter("published", false))
    .get()
    .await?;

// Shortcut: where_has + single column == match.
let same = User::query()
    .where_relation("posts", "published", true)
    .get()
    .await?;

// where_belongs_to - direct FK = ? on THIS table (no EXISTS needed,
// since the FK is on the child row).
let mine = Post::query()
    .where_belongs_to("author", user.id)
    .get()
    .await?;

You query a MorphTo with has_morph, doesnt_have_morph, where_has_morph or where_doesnt_have_morph. You supply morph names, or "*" for every target the relation declares in targets = [...]. A target without morph_type answers to its snake-cased type name, the string its parent writes. The where_ closure receives Builder<()> and the canonical morph name per type. Wildcard absence also includes null type columns. See MorphTo existence.

has, has_count and doesnt_have on a MorphTo ask every target the relation declares, as Laravel's has does: has("commentable") renders what has_morph("commentable", "*") renders, and doesnt_have keeps every other row, including a null type and a type no target answers to. where_has::<R, _> and where_doesnt_have::<R, _> apply your closure to the owners of R's type only, because the closure is written against one model; use where_has_morph to constrain several types.

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 add AND 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> ...).
  • HasOneThrough / HasManyThrough → joins the target through the intermediate: EXISTS (SELECT 1 FROM intermediate INNER JOIN target ON intermediate.<second_local_key> = target.<second_key> WHERE intermediate.<first_key> = parent.<pk>). A soft-deleting intermediate adds AND intermediate.<deleted_at> IS NULL, so a row reached through a trashed intermediate does not count.

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.

When a relation points back at its own table, such as a category's children through parent_id or a self-referential BelongsToMany, the engine aliases the inner tables inside the subquery (FROM categories AS __suprnova_related), so your filter compares each row with its related rows and not with itself. Your outer query keeps its table name, so a qualified column such as categories.name in a where_has closure still refers to the parent row. A relation that points at another table renders without the alias.

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 = User::query().limit(100).get().await?;
for u in &users {
    let posts = u.posts().get().await?;
    /* ... */
}

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 = User::query()
    .with(["posts"])
    .limit(100)
    .get()
    .await?;

for u in &users {
    for post in u.posts_loaded() {       // reads from cache, no SQL
        println!("{}: {}", u.name, post.title);
    }
}

Nested paths work too - dot-separated relation names recurse:

let users = User::query()
    .with(["posts.comments.author"])
    .get()
    .await?;
// 4 queries: users, posts IN users.id, comments IN posts.id, authors IN comments.user_id.

You cap eager has-one, has-many, morph-one and morph-many reads per parent with limit(n) or take(n) in your constraint closure. Each parent keeps up to that many matching children, in your chosen order. The child query uses ROW_NUMBER() partitioned by its foreign key, including on SQLite. Without an explicit order, you receive rows in primary-key order. A zero limit loads no children. Lazy relation access keeps a plain LIMIT.

let posts = Post::query()
    .with_where(("comments", |q: Builder<Comment>| q.order_by_desc("id").limit(2)))
    .get()
    .await?;

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 = User::query().with_count(["posts"]).get().await?;
for u in &users {
    println!("{} has {} posts", u.name, u.posts_count());
}

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 = User::query()
    .with_count(["posts"])
    .with_sum(("posts", "views"))
    .with_avg(("posts", "views"))
    .get()
    .await?;

for u in &users {
    println!(
        "{}: {} posts, {} views total, {} avg",
        u.name,
        u.posts_count(),
        u.posts_sum_of("views").unwrap_or(0.0),
        u.posts_avg_of("views").unwrap_or(0.0),
    );
}

A count or an aggregate covers the rows with would load: the related model's soft-delete filter and its global scopes apply, so a trashed post is neither counted nor summed. A HasManyThrough count also leaves out rows reached through a trashed intermediate. The count of a many-to-many relation counts its pivot rows, the attachments, as user.roles().count() does.

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 suprnova::Builder;

let users = User::query()
    .with_where(("posts", |q: Builder<Post>| q.filter("published", true)))
    .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!["rust".to_string(), "web".to_string()];
let users = User::query()
    // `wanted.clone()` inside, not `move` of `wanted` itself - the
    // closure may run once per clone of the builder.
    .with_where(("posts", move |q: Builder<Post>| q.filter_in("tag", wanted.clone())))
    .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 = User::query().with(["posts"]).filter("active", true);

let first_page = base.clone().limit(20).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 = User::query().get().await?;

users.load(["posts"]).await?;                 // unconditional
users.load_missing(["posts.comments"]).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 = User::query()
    .with(["profile", "posts", "team"])
    .without(["team"])     // drops team from the plan
    .get()
    .await?;

Touching owners

A child can declare that writing it should freshen its owner's updated_at:

#[model(
    table = "comments",
    touches = ["post"],
    relations = {
        post: BelongsTo<Post> { fk = "post_id" },
    },
)]
pub struct Comment {
    pub id: i64,
    pub post_id: i64,
    pub body: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

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.

impl User {
    /// Custom: most-recent post regardless of FK shape.
    pub async fn latest_post(&self) -> Result<Option<Post>, FrameworkError> {
        Post::query()
            .filter("user_id", self.id)
            .latest()
            .first()
            .await
    }
}

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.