Suprnova's Eloquent layer gives Laravel developers the API they know,
implemented as a thin shim over SeaORM. Copy code from the Laravel
docs, swap PHP syntax for Rust, add .await?, and it runs.
The whole layer is a struct attribute (#[suprnova::model]), a trait
(Model), and a chainable query builder (Builder<M>) - that's it.
Behind the scenes the macro generates a SeaORM Entity, Model,
ActiveModel, and Column enum, plus every Eloquent trait impl. The
SeaORM types stay reachable for the rare case the Eloquent surface
doesn't cover (see the SeaORM escape hatches).
Table of contents
- Quick start
- The
#[suprnova::model]attribute - Model module layout
- Finding rows
- Creating and updating
- Deleting and soft deletes
- Query builder - dual API
- Row locking
- Transactions
- Scopes
- Relationships
- Eager loading
- Preventing lazy loading
- Pagination
- Chunking and lazy iteration
- Collections
- Mass assignment
- Casts
- Accessors and mutators
- Timestamps
- Observers and lifecycle events
- Prunable
- Multi-connection routing
- Replication
- Debugging - dump and dd
- Testing models
- Dropping to SeaORM
- Migrating from
database::Model - DB facade - model-less queries
- Laravel-13 parity - relation-existence + cheap shortcuts
Quick start
One attribute on a struct turns it into a fully-featured Eloquent model:
use ;
use ;
Once declared, you can write:
User::query()- start a fluent query builder.User::find(id).await?- fetch by primary key.User::find_or_fail(id).await?- same, but errors withModelNotFoundon miss.User::all().await?- every row.User::create(attrs!{ name: "Alice", email: "alice@example.com" }).await?- insert with mass-assignment filtering.User::filter("email", "alice@example.com").first().await?- one row that matches.user.update(attrs!{ name: "Alice B" }).await?- partial update.user.save().await?- persist in-memory changes.user.delete().await?- remove the row.user.refresh().await?/user.fresh().await?/user.replicate().await?- the rest of the Laravel lifecycle.
The user-facing struct (here User) IS the type your handlers and
controllers carry. The macro emits a per-model inner module (user::)
with the SeaORM Entity, Column, ActiveModel, and Model types
for the cases where you want to drop down to SeaORM directly. The
struct is also registered in an inventory-backed ModelEntry so
admin and tooling code can enumerate every model at boot.
The #[suprnova::model] attribute
The single entry point for declaring a model. Every attribute is
optional; the defaults are tuned so a struct with id +
created_at + updated_at works as a Suprnova model with zero
configuration.
Macro attribute reference
| Attribute | Type | Default | Notes |
|---|---|---|---|
table |
string | snake_case-plural of struct name | Override the table name |
primary_key |
string | "id" |
Override the PK column name |
key_type |
type | the primary-key field's type | Optional. The key type comes from the field primary_key names; a key_type that names another type fails the build |
auto_increment |
bool | true |
Disable for UUID PKs |
connection |
string | "default" |
Multi-connection apps name a non-default connection |
fillable |
list of strings | (default = guarded = ["id"]) |
Mass-assignment allowlist |
guarded |
list of strings | ["id"] when neither set |
Mass-assignment denylist (mutually exclusive with fillable) |
defaults(field = value, ...) |
field expressions | none | Set the values you read on a new instance and save when a create omits them |
casts |
map of field = CastType |
{} |
Per-column casts |
hidden |
list of strings | [] |
Excluded from to_json / to_array |
visible |
list of strings | (all) | Inclusive variant of hidden (mutually exclusive) |
appends |
list of strings | [] |
Accessors to include in serialization |
soft_deletes |
flag | false |
Enable deleted_at column + tombstone semantics |
soft_deletes_column |
string | "deleted_at" |
Override the soft-delete column name |
timestamps |
flag / bool | true when both created_at and updated_at exist |
Disable auto-managed timestamps |
created_at |
string | "created_at" |
Override the column name |
updated_at |
string | "updated_at" |
Override the column name |
touches |
list of relation names | [] |
BelongsTo or MorphTo relations whose owner row gets its updated_at bumped after this model is created, saved, updated, or deleted |
mutators |
list of strings | [] |
Field names whose JSON-fill path routes through a set_<field>(value) mutator method |
route_key |
string | the primary key | The column a route parameter without a binding field matches (Laravel's getRouteKeyName()). A name that is no column of the model fails the build. See Route model binding |
custom_route_binding |
flag | off | The model implements RouteBinding itself; the macro emits none. suprnova::database::resolve_model_route_binding stays callable from your impl |
Full example
use ;
use Value as Json;
use ;
Function-level macros
Function-level macros work alongside the struct attribute:
#[accessor]on afn name(&self) -> Tmakes it an Eloquent accessor. The model'sto_array()calls it whennameis listed inappends = [...](andto_json()picks it up via theto_array→ string delegation).#[mutator]on afn set_name(&mut self, value: serde_json::Value)makes it an Eloquent mutator. The model's JSON-fill path routes through it whennameis listed inmutators = [...].#[suprnova::scopes(Model)]on animpl Model { ... }block: every method whose signature isfn name(query: Builder<Self>[, args…]) -> Builder<Self>becomes both a chainable.scope_name(args)onBuilder<Self>and aModel::scope_name(args)shortcut. There is no function-level#[scope]form - scopes are declared per impl-block.- Global scopes are a runtime registration via the
GlobalScopetrait, registered withScopeRegistry::register::<M, _>(scope). There is no function-level#[global_scope]macro - see Macros for the full pattern. #[prunable]onimpl Prunable for T { ... }registers the pruner via inventory somodel:prunefinds it.
Model module layout
#[suprnova::model] keeps your user-facing struct (e.g. Post) at
parent scope and emits a sibling pub mod named after the struct in
snake_case (post). That inner module is where the SeaORM types live.
For a model declared at app/src/models/posts.rs:
use ;
use model;
// Convention: re-export the SeaORM types the macro emits inside the
// inner module so call sites can use the unprefixed names. Suprnova's
// own dogfood models all carry this line (see `app/src/models/users.rs`,
// `app/src/models/posts.rs`, etc.).
pub use ;
You now have these items reachable from crate::models::posts:
| Path | What it is |
|---|---|
crate::models::posts::Post |
Your user-facing struct - the Eloquent model |
crate::models::posts::post::Entity |
SeaORM EntityTrait impl for the posts table |
crate::models::posts::post::Column |
SeaORM Column enum (one variant per column) |
crate::models::posts::post::ActiveModel |
SeaORM ActiveModel for insert/update |
crate::models::posts::post::Model |
SeaORM-shape row (storage-typed columns) |
crate::models::posts::{Entity, Column, ActiveModel} |
The pub use convention above; not auto-emitted |
Two things to know about the inner module's Model:
- It is the SeaORM-shape row, not your
Poststruct. Cast columns carry theirStoragetype here (e.g.boolbecomes the underlying integer), and the__eager/__pivotruntime slots from your struct are absent. From<post::Model> for PostandFrom<Post> for post::Modelbridge the two shapes. See Dropping to SeaORM for the round-trip pattern.
Model is intentionally not part of the conventional parent
re-export - the user-facing Post already occupies the Post name at
parent scope, and post::Model is a separate type that callers reach
through post::Model (or From conversion) when they need the inner
shape.
When to reach into the inner module
The Eloquent surface (Model trait + Builder<M>) covers the vast
majority of queries. Reach into post::* when you need SeaORM-only
features:
- Raw query construction with SeaORM's
EntityTrait::find()chain when Eloquent doesn't expose the helper you want. - Custom join logic - building
JoinType::*joins explicitly viaQuerySelect::join()for a relation Eloquent'swith(...)doesn't model. - SeaORM-native subqueries through
Entity::find().select_only(). - Plain
ActiveModelmutation for the rare case you want to bypass the Eloquent lifecycle (no observers, no auto-timestamps).
// Common case - Column re-exported at parent module level via the
// `pub use post::{...}` convention above.
use crateColumn;
let drafts = query
.db_where
.get
.await?;
// Power-user case - reach into the inner module for the SeaORM Entity
// directly. This is what the parent `pub use` does not surface.
use cratepost;
use ;
let db = DBconnection?;
let rows: = find
.filter
.all
.await?;
// Bridge back to the Eloquent shape when the caller wants it.
let posts: = rows.into_iter.map.collect;
If you find yourself reaching into the inner module routinely for the
same operation, that's a signal Eloquent is missing a helper - open an
issue, or add the helper to the Model / Builder surface.
Finding rows
// Suprnova
let user: = find.await?;
let user: User = find_or_fail.await?;
let users: = find_many.await?;
find_or_fail returns FrameworkError::ModelNotFound (HTTP 404 when
bubbled to a controller).
first_or_create / update_or_create / first_or_new / first_or
// Suprnova
let user = first_or_create.await?;
let user = update_or_create.await?;
let user = first_or_new.await?; // returns an unsaved User; caller inserts it explicitly
let user = user.persist.await?; // `use suprnova::Persistable;`
Lookup keys go in the first map; extra fields applied on the
create-path go in the second map. Returning an unsaved model via
first_or_new lets the caller mutate it further before inserting it
with persist().await?, which fires creating and created and
returns the saved model with the key the database assigned. When
first_or_new finds a row, the model it returns is loaded, and save
updates it.
Why Suprnova diverges
Laravel's save inserts a model that does not exist yet and writes the
new key into it. Suprnova's save borrows the model, so it cannot hand
the new key back. A model built in the process whose key still holds its
reset value - a new model from first_or_new or find_or_new, or a
replica - has no row to update, so save refuses it with an error that
names persist. persist consumes the model and returns the inserted
one, key included.
Creating and updating
Create
// Suprnova
let user = create.await?;
attrs! is a macro that produces an Attrs value (a typed JSON
map). Pure JSON also works -
User::create(serde_json::json!({"name": "Alice", "email": "..."})).
The Fillable filter runs inside create; non-fillable fields are
silently dropped, matching Laravel's behaviour.
Declared defaults
Declare defaults(field = value, ...) to give new instances initial values:
use ;
let draft = new;
assert_eq!;
let draft = create.await?;
let live = create.await?;
You read declared defaults before saving a new instance. A partial create
uses every declared default that your filtered attributes omit, before
creation events and the insert. Your supplied attributes win. The same
rules apply to create_with_tx and unsaved instances from first_or_new.
You supply values convertible into the declared field types, so "draft"
works for a String. You name each column at most once. An unknown column
or a value of the wrong type fails the build. Your defaults are trusted
model values; your caller's attributes still pass through the fillable
filter. A model without the declaration keeps its existing construction
and insert behavior.
Save / update
// Suprnova
user.name = "Alice B".into;
user.save.await?;
user.update.await?;
You call save() after changing a loaded model. It compares your values
with the original values kept in memory, without re-reading the row. A
clean save issues no UPDATE and fires neither Updating nor Updated;
Saving and Saved still fire. A Saving listener can make the model
dirty by changing its attributes. A dirty save writes changed columns and
updates its timestamp.
You use update(attrs) to apply a partial attribute map, with the
Fillable filter and declared mutators, before writing.
Changes after a save
After a save, a model reports which attributes that save changed. While
the save's updated and saved observers run, the original values are
still the ones loaded before the save; once the save returns, they are the
saved ones:
// Suprnova
use Model;
user.is_admin = true;
user.save.await?;
user.was_changed; // true
user.was_changed_any; // false
user.was_changed_any; // true: the save changed something
user.get_changes; // Attrs { "is_admin": 1 }
user.get_original?; // Some(true): the saved value
user.get_raw_original; // Some(1), as stored
user.get_previous; // Some(0): before the change
An observer reads the value from before the save, which is how an audit trail records both sides of a change:
use async_trait;
use Observer;
use ;
;
The rules follow Laravel's save:
- A column counts as changed when the value the database stores after the
save differs from the value the model held before it. Writing the same
value back is not a change. A column that a
Savinglistener or the timestamps rewrote is one, so a model with timestamps also reportsupdated_at. An encrypted column stores a new ciphertext on every save, so it is compared by its decrypted value instead: it counts as changed only when that value changed. get_previous(attribute)returns the stored value before the last change for that attribute. You getNonefor an unchanged or unknown attribute. A clean or failed save keeps this record.get_changesandget_raw_originalreturn stored values, before casts, as Laravel does.get_originalreads the value through the model's casts and returns an error only when the stored value no longer decodes.- The record describes the last save that changed something. A later save that changes something replaces it. A save that changes nothing, or a save that fails, leaves it in place.
save,update,save_with_tx, andupdate_with_txrecord a save. A model youcreatereports no changes, and a model you load reports none until you save it. The original is the row as the model last read or saved it, whatever you change in memory. To answer this, every model a query loads keeps a copy of its stored row beside its fields, so a large result takes more memory than the fields alone. A clone keeps the record;replicatebuilds a new model without one.- A model you build in memory and save without reading it first has no
loaded values to compare with: every column counts as changed, and
get_originalreturnsNoneuntil that save returns. Such a model needs a key a row already holds: while its key holds the reset value, as a replica's and a newfirst_or_newmodel's do,saverefuses it andpersistinserts it.
Why Suprnova diverges
A save that changes nothing runs no UPDATE and fires neither updating
nor updated, as in Laravel, while saving and saved still fire; the
previous save's change record stays in place. refresh replaces the model
with a fresh read, so it also clears the record, where Laravel's refresh
keeps getChanges.
Increment / decrement
// Suprnova
user.increment.await?;
user.increment.await?;
user.decrement.await?;
filter.increment.await?;
increment / decrement emit UPDATE table SET col = col + N WHERE ... SQL - atomic against concurrent updates, no read-modify-write
race. Available both on a fetched model instance (uses the row's PK
in the WHERE clause) and as a Builder terminal (uses the chain's
WHERE clauses).
Fresh / refresh / replicate
// Suprnova
user.refresh.await?;
user.refresh_for_update.await?;
let copy: User = user.fresh.await?;
let replica: User = user.replicate.await?;
let replica: User = user.replicate_except.await?;
refresh mutates in place; fresh returns a separately-fetched
copy. refresh_for_update is refresh under a SELECT ... FOR UPDATE
row lock - use it inside a transaction when you need the row's current
values and the exclusive lock in one statement. Unlike refresh,
refresh_for_update bypasses every registered global scope AND the
#[model(soft_deletes)] filter: it reloads a trashed row too, with
deleted_at coming back set. The reload is a lookup by primary key
under a lock - scoping it the way an ordinary read is scoped would
hand admin tooling and cross-tenant callers a false not-found for a
row they already hold a reference to. replicate builds an
in-memory clone with the PK and the timestamps reset
(Default::default() for each type). Insert it with
replica.persist().await? (the Persistable trait), which stamps
created_at and updated_at the way create does. replica.save()
refuses it: the replica has no row yet, and its reset key would name
another row.
refresh and refresh_for_update both return an error when the row no
longer exists, rather than leaving the model holding stale values.
SQLite has no row-level locking, so refresh_for_update reloads without
a lock there - see Row locking.
Replicating event
replicate and replicate_except fire the per-model
Replicating { source, replica } event after constructing the
in-memory clone and BEFORE returning it. The replica field is an
Arc<tokio::sync::Mutex<Self>> so listeners can mutate the replica
before the caller sees it - useful for prefixing titles with
(copy), clearing flags, resetting derived columns, etc.
use ;
use async_trait;
;
// Wire it up once at boot:
.await;
Cross-type replication
let replica: UserDraft = user.replicate_into.await?; // cross-type clone
A Suprnova divergence - Laravel can't do this because PHP doesn't have types. Useful when promoting a draft model into a final one or vice-versa.
replicate_into<T> does NOT fire Replicating (the event carries
Arc<Mutex<Self>>, so a listener on the source type couldn't mutate
the cross-type replica anyway). Callers wanting per-T setup should
run it on the returned T before calling persist - the normal
Creating / Saving / Created / Saved chain still fires inside
persist.
Deleting and soft deletes
Soft deletes flag
Add soft_deletes to the macro attribute and a
deleted_at: Option<DateTime<Utc>> column to the struct:
Lifecycle
user.delete.await?; // UPDATE: sets deleted_at (and updated_at) = NOW()
user.trashed; // -> true
let trashed = with_trashed.find.await?.unwrap;
trashed.restore.await?; // UPDATE: sets deleted_at = NULL
let only_dead = only_trashed.get.await?;
let all_including_dead = with_trashed.get.await?;
user.force_delete.await?; // actual DELETE
delete() sets updated_at along with deleted_at when the model manages
timestamps, as Laravel's soft delete does. The mass form follows the row
form. delete_all() on a builder soft-deletes every row the query matches
with one UPDATE, and it sets updated_at too. force_delete_all() removes the rows for good.
Neither fires per-row events. See
Mass mutation.
query.filter.delete_all.await?; // soft delete
only_trashed.force_delete_all.await?; // actual DELETE
Default scope
When soft_deletes is set, the macro overrides Model::query() so
default reads filter out trashed rows automatically. with_trashed()
and only_trashed() opt back in. Concretely: User::find(id)
skips trashed rows; User::with_trashed().find(id) finds them.
Query builder - dual API
Builder<M> is the chainable query type returned by User::query(),
User::filter(...), User::db_where(...), and every other static
method that doesn't terminate the chain.
Naming note: dual API
where is a Rust keyword, so the bare-equality where method can't
share Laravel's name. Rather than pick a winner, every where-shape
method ships under both a Rust-idiomatic name (filter,
filter_in, filter_null, …) and a Laravel-shape name (db_where,
where_in, where_null, …). They're aliases over one canonical
implementation - pick whichever your muscle memory matches.
// Rust dev:
query.filter.filter_in.get.await?;
// Laravel dev:
db_where.where_in.get.await?;
// Same query. Same result. Different muscle memory.
Where shortcuts
// Suprnova - pick either family; both compile, both documented.
// Rust-shape (filter family):
let users = query.filter.get.await?;
let users = query.filter_op.get.await?;
let users = query.filter_like.get.await?;
// Laravel-shape (db_where / where_* family):
let users = db_where.get.await?;
let users = query.db_where_op.get.await?;
let users = query.where_like.get.await?;
Where variants
Every row has two equivalent Suprnova forms - Rust-shape (filter*)
and Laravel-shape (db_where / where_*). Both call the same
canonical implementation; both are tagged with #[doc(alias = "...")]
so rustdoc search finds either.
| Laravel | Suprnova (Rust-shape) | Suprnova (Laravel-shape) | Notes |
|---|---|---|---|
->where(col, val) |
.filter(col, val) |
.db_where(col, val) |
Equality |
->where(col, op, val) |
.filter_op(col, op, val) |
.db_where_op(col, op, val) |
Arbitrary operator |
->orWhere(...) |
.or_filter(...) |
.or_where(...) |
|
->orWhereKey(id) |
.or_filter_key(id) |
.or_where_key(id) |
PK filter as a disjunct |
->orWhereKeyNot(id) |
.or_filter_key_not(id) |
.or_where_key_not(id) |
Negated PK filter as a disjunct |
->whereNot(col, val) |
.filter_not(col, val) |
.where_not(col, val) |
|
->whereIn(col, vals) |
.filter_in(col, vals) |
.where_in(col, vals) |
A list or a DB::table subquery |
->whereNotIn(col, vals) |
.filter_not_in(col, vals) |
.where_not_in(col, vals) |
A list or a subquery |
->orWhereIn(col, vals) |
.or_filter_in(col, vals) |
.or_where_in(col, vals) |
A list or a subquery |
->orWhereNotIn(col, vals) |
.or_filter_not_in(col, vals) |
.or_where_not_in(col, vals) |
A list or a subquery |
->whereAny(cols, op, v) |
.filter_any(cols, op, v) |
.where_any(cols, op, v) |
(c1 op v OR c2 op v ...) |
->orWhereAny(cols, op, v) |
.or_filter_any(cols, op, v) |
.or_where_any(cols, op, v) |
|
->whereAll(cols, op, v) |
.filter_all(cols, op, v) |
.where_all(cols, op, v) |
(c1 op v AND c2 op v ...) |
->orWhereAll(cols, op, v) |
.or_filter_all(cols, op, v) |
.or_where_all(cols, op, v) |
|
->whereNone(cols, op, v) |
.filter_none(cols, op, v) |
.where_none(cols, op, v) |
NOT (c1 op v OR c2 op v ...) |
->orWhereNone(cols, op, v) |
.or_filter_none(cols, op, v) |
.or_where_none(cols, op, v) |
|
->whereBetween(col, [a, b]) |
.filter_between(col, a..=b) |
.where_between(col, a..=b) |
Rust range |
->whereNotBetween(col, [a, b]) |
.filter_not_between(col, a..=b) |
.where_not_between(col, a..=b) |
|
->whereNull(col) |
.filter_null(col) |
.where_null(col) |
|
->whereNotNull(col) |
.filter_not_null(col) |
.where_not_null(col) |
|
->orWhereNull(col) |
.or_filter_null(col) |
.or_where_null(col) |
|
->orWhereNotNull(col) |
.or_filter_not_null(col) |
.or_where_not_null(col) |
|
->whereDate(col, '2026-05-19') |
.filter_date(col, NaiveDate) |
.where_date(col, NaiveDate) |
|
->whereMonth(col, 5) |
.filter_month(col, 5) |
.where_month(col, 5) |
|
->whereDay(col, 19) |
.filter_day(col, 19) |
.where_day(col, 19) |
|
->whereYear(col, 2026) |
.filter_year(col, 2026) |
.where_year(col, 2026) |
|
->whereTime(col, '12:30') |
.filter_time(col, NaiveTime) |
.where_time(col, NaiveTime) |
|
->whereLike(col, pattern) |
.filter_like(col, pattern) |
.where_like(col, pattern) |
|
->whereNotLike(col, pattern) |
.filter_not_like(col, pattern) |
.where_not_like(col, pattern) |
|
->whereBinary(col, val) |
.filter_binary(col, val) |
.where_binary(col, val) |
Byte-exact; MySQL and MariaDB only |
->orWhereBinary(col, val) |
.or_filter_binary(col, val) |
.or_where_binary(col, val) |
|
->whereNotBinary(col, val) |
.filter_not_binary(col, val) |
.where_not_binary(col, val) |
|
->orWhereNotBinary(col, val) |
.or_filter_not_binary(col, val) |
.or_where_not_binary(col, val) |
|
->whereJsonContains(col, v) |
.filter_json_contains(col, v) |
.where_json_contains(col, v) |
Backend-dispatched |
->whereJsonLength(col, op, n) |
.filter_json_length(col, op, n) |
.where_json_length(col, op, n) |
|
->whereColumn(a, b) |
.filter_column(a, b) |
.where_column(a, b) |
Column-to-column compare |
->whereExists(query) |
.filter_exists(query) |
.where_exists(query) |
A DB::table subquery |
->whereNotExists(query) |
.filter_not_exists(query) |
.where_not_exists(query) |
|
->whereHas(rel, closure) |
.filter_has(rel, fn) |
.where_has(rel, fn) |
Relation predicate (10B) |
->whereDoesntHave(rel) |
.filter_doesnt_have(rel) |
.where_doesnt_have(rel) |
(10B) |
->whereRelation(rel, col, op, v) |
.filter_relation(...) |
.where_relation(...) |
(10B) |
->whereRaw(sql, bindings) |
.filter_raw(sql, bindings) |
.where_raw(sql, bindings) |
|
->orWhereRaw(sql, bindings) |
.or_filter_raw(sql, bindings) |
.or_where_raw(sql, bindings) |
Every or_* method adds a flat OR condition, so
r#where(a).or_where(b).r#where(c) means a OR b AND c. You use
r#where(column, value) or db_where(column, value) for equality, and
filter_op or or_where_op for an explicit operator. A null equality
value compiles to IS NULL. Global scopes keep your complete condition
list in a group when it contains an OR.
The grouped helpers keep their comparisons in parentheses: this query never
returns a user whose active is false, and the model's soft-delete
filter still applies:
// WHERE deleted_at IS NULL AND active = ? AND (is_admin = ? OR is_staff = ?)
let staff = query
.filter
.where_any
.get
.await?;
An empty column list adds no condition. where_in and its siblings
take another DB::table builder as a subquery, and so do
where_exists and where_not_exists, whose subquery can refer to the
model's table through where_column:
use DB;
let booked = query
.where_in
.get
.await?;
let authors = query
.where_exists
.get
.await?;
A subquery's values are bound parameters in their place in the
statement. For a declared relation, has and where_has build the
EXISTS subquery for you. The full subquery and OR rules are in
Query Builder.
The binary family compares raw bytes instead of matching under the
column's collation. MySQL and MariaDB emit col = binary ?; Postgres
and SQLite have no equivalent operator, so a terminal on those backends
returns an error when the statement renders rather than falling back to
a collation-dependent =. See Byte-exact comparison.
Bound raw predicates use portable ? markers on SQLite, MySQL, and PostgreSQL:
let rows = query
.filter
.filter_raw
.get
.await?;
On PostgreSQL, Suprnova rebases those markers after earlier query bindings, so
the example renders $1 for active and $2/$3 for the raw predicate. Use
?? for a literal question-mark operator in a bound raw fragment, such as
"payload ?? 'enabled' AND status = ?". Existing $N fragments remain
accepted, but portable markers avoid coupling call sites to query position.
Mixed marker styles and marker/binding count mismatches are rejected before
database I/O. As with every raw expression, the SQL text must be trusted;
untrusted values belong only in the bindings vector.
Ordering
let users = query.order_by.get.await?;
let users = query.order_by_desc.get.await?;
let users = latest.get.await?;
let users = oldest.get.await?;
let users = query.in_random_order.get.await?;
Direction::Asc / Direction::Desc is the Suprnova enum
re-exported from SeaORM.
reorder() drops every ordering set so far, and reorder_by(col, dir)
drops them and orders by col instead. Orderings a global scope adds
when the query runs stay, as in Laravel:
let users = latest.reorder_by.get.await?;
Ordering by an explicit sequence
in_order_of sorts rows into the order you list. Anything whose value
is not in the list sorts after everything that is.
let users = query
.in_order_of
.get
.await?;
Suprnova renders this as a bound CASE expression, so the values are
parameters and are safe to take from request data:
ORDER BY CASE WHEN role = ? THEN 0 WHEN role = ? THEN 1 WHEN role = ? THEN 2 ELSE 3 END
The column name is an SQL identifier, not a parameter. Hardcode it or pick it from an allowlist, the same as every other column argument. An empty value list adds no ordering at all, so you can build the sequence conditionally without special-casing the empty case.
For a column that uses the AsEnum<E> cast, pass each variant through
as_ref(). That is the exact string the cast stores:
let users = query
.in_order_of
.get
.await?;
in_order_of ships on the typed Builder<M> surface. The model-less
DB::table(...) builder orders by column and direction only.
Grouping + having
let rows = query
.group_by
.having_op
.get
.await?;
Limit / offset
let users = query.limit.offset.get.await?;
let users = query.take.skip.get.await?;
Select / add_select / select_raw
let users = query.select.get.await?;
let users = query.select.add_select.get.await?;
let rows = query.select_raw
.group_by
.get_raw
.await?;
get_raw() returns the raw column-shape result for select_raw
cases where the columns don't match the model schema; get()
returns Vec<User> and requires the selected columns to fill the
model struct.
Distinct
let emails: = query.distinct.pluck.await?;
Aggregates
let count = count.await?;
let count = filter.count.await?;
let sum = .await?;
let visits = .await?;
let avg = .await?;
let exact = .await?;
let min = .await?;
let max = .await?;
let exists = filter.exists.await?;
let missing = filter.doesnt_exist.await?;
count returns an i64. sum, avg, min and max take the type to
read as a type parameter; for avg it is f64 or rust_decimal::Decimal
(the AvgValue trait). Suprnova aliases generated
aggregate expressions internally so the same typed result is decoded on
PostgreSQL, MySQL, and SQLite. sum and avg return zero for an empty
match set, while min and max return None. The same holds when no row
comes back at all - an offset skips the aggregate's one row, as
skip(10).count() does, or a grouped query has no group: count, sum
and avg return zero and min and max return None, as Laravel's
count, sum, min and max do. An incompatible requested
Rust type or missing result column is a database error; it is never
converted into a plausible zero or None.
The database chooses the type of a sum or an average: PostgreSQL answers
numeric for the sum of a bigint column and for any average of
integers, MySQL answers DECIMAL, and SQLite answers an integer or a
real. sum and avg read whichever arrives. An integer type such as
i64 or u64 takes a sum exactly, and a sum with a fraction, or outside
the type's range, is an error rather than a truncated value. Decimal
takes a sum or an average exactly, so the average of a DECIMAL money
column keeps every digit, and a value it can't hold is an error. f64
takes the nearest value. SQLite has no exact decimal type: it stores a
DECIMAL column as a real, and Decimal reads that real.
Terminals
let users: = all.await?;
let first: = first.await?;
let user: User = first_or_fail.await?;
let value: = filter.value.await?;
let emails: = .await?;
let keyed: = .await?;
let ids: = query.model_keys.await?;
let sql: String = filter.to_sql;
value, pluck and pluck_keyed skip a NULL: value returns None
and pluck leaves the row out. A value that doesn't read as the type you
name is an error that names the column, rather than a missing row. These
terminals, the aggregates and DB::scalar read u64 and Option<u64> on
every database, as a model's u64 field does: on Postgres and SQLite a
negative value fails the read. A read by a u64 above i64::MAX answers
what the database holds. On Postgres a signed integer column holds only
integers, so no row matches and the query gets its answer without
sending the value: find returns None, find_many skips it, and in a
filter =, >, >= and IN match no row, while !=, <, <= and
NOT IN match every row whose column is not NULL. SQLite compares the
value's digits, as it does a literal, because an INTEGER column there can
hold a REAL above i64::MAX that raw SQL or an older write left. MySQL
compares the unsigned number.
The same holds for a column of a narrower integer field such as i64 or
i32, which can't hold such a value on any database, and a mass update
that writes one to it is refused on Postgres and SQLite before anything is
sent. A column the model doesn't know, such as a joined table's column,
compares as the number on every database, as with DB::table. A mass
write (update_all, upsert) to a field that is neither an integer nor
text, such as a decimal, also takes the value as DB::table does: a
numeric column on Postgres or MySQL stores it exactly, and SQLite refuses
it for a column of INTEGER or NUMERIC affinity, which would store a
rounded REAL.
to_sql returns the parameterised SQL the next terminal would emit -
useful for debugging or building views. The bindings are
accessible via .to_sql_with_bindings() -> (String, Vec<Value>).
model_keys is the key-only terminal: it projects the qualified
primary key (users.id) and never hydrates a model, so a "which rows
matched?" question costs one column instead of a full row per match.
The qualification is what lets it survive a query that joins another
table carrying its own id. Any select(...) already on the builder is
discarded - the caller asked for keys.
Joins
join, left_join, right_join, and cross_join work as they do on
the DB::table builder, along with the closure forms (join_with,
left_join_with, right_join_with, cross_join_with) and the subquery joins (join_sub,
left_join_sub, join_sub_with, left_join_sub_with). See
Query Builder - Joins for the conditions a join
takes.
let posts = query
.join
.filter
.get
.await?;
Without a select, a model query with a join selects only the model's
own columns ("posts".*), so the joined table's id, name, or
created_at never lands in the model. Call select to choose the
columns yourself; select(["posts.*"]) is the default spelled out.
Qualify a column that both tables carry, such as users.active above.
On a model that soft-deletes, the soft-delete filter names the model's
table once the query joins another, so a joined table with its own
deleted_at doesn't make the column ambiguous. The joined table's own
soft-delete filter isn't applied, as in Laravel.
count and paginate count the joined rows. You use update or
update_all to update matching base rows, and delete or delete_all
to delete them. On a soft-delete model, deletion writes its tombstone;
force_delete_all removes the rows. Every join and predicate still
constrains these writes. increment_each still refuses joins.
You compare two columns with where_column(a, b) for equality or
where_column_op(a, op, b) for another allowed operator.
Unions
let first = filter;
let second = filter;
let users = first.union.get.await?;
let users = first.union_all.get.await?;
As in Laravel, an ordering, a limit, or an offset belongs to the whole
union when you add it after union, and to the first query alone when
you add it before. paginate, simple_paginate, cursor_paginate,
first, and count all come after union, so they page, take, and
count the rows of the union; a cursor bounds the rows of every arm:
let page = filter
.union
.order_by_desc // orders the union
.paginate // pages the union; `total` counts its rows
.await?;
let latest_active = filter
.order_by_desc
.limit // the ten newest active users only
.union;
The union is wrapped as a subquery for these clauses, so order it by the
bare names of its columns (id, not users.id). A UNION keeps one
copy of a row both queries return, and total counts it once;
union_all keeps both.
Row locking
Two builder methods request a per-row database lock at SELECT time:
// Exclusive write lock - blocks other transactions trying to lock
// or write the same rows until this transaction commits.
let order = query
.filter
.lock_for_update
.first_or_fail
.await?;
// Shared read lock - allows other shared readers, blocks writers.
let inventory = query
.filter
.shared_lock
.first_or_fail
.await?;
Per-backend SQL emitted:
| Backend | lock_for_update() |
shared_lock() |
|---|---|---|
| Postgres | FOR UPDATE |
FOR SHARE |
| MySQL | FOR UPDATE |
LOCK IN SHARE MODE |
| SQLite | (no SQL, see below) | (no SQL, see below) |
The lock clause is appended at the very end of the compound
statement - after every UNION arm, every ORDER BY, every
LIMIT / OFFSET. A union(...) of two builders followed by
.lock_for_update() emits exactly one FOR UPDATE at the
outer scope, not one per arm.
To reload a model you already hold and take the lock in the same
statement, use refresh_for_update:
DBtransaction.await?;
Use inside a transaction
The lock only does useful work inside a transaction - without
one, the SQL still emits but the lock releases at statement end.
Pair with DB::transaction(...):
DBtransaction.await?;
lock_for_update vs shared_lock
Most "read then write" flows want lock_for_update. A shared
lock still lets another shared_lock reader race you to a
following UPDATE - only FOR UPDATE is mutually exclusive.
shared_lock is right for consistent snapshot reads where you
read a row, derive a decision from it, and don't write back -
e.g. an inventory check that doesn't itself decrement stock.
SQLite
SQLite has no row-level locking. It uses file-level transaction
locking only (BEGIN IMMEDIATE / BEGIN EXCLUSIVE). The lock
methods are kept in the SQLite path so cross-backend code
compiles, but they emit no SQL.
The first time per process that lock_for_update / shared_lock
runs against a SQLite backend, the framework logs a single
warn! on the suprnova::eloquent::lock tracing target. This
surfaces the no-op without spamming high-volume code paths.
If you need cross-row contention guarantees on SQLite, wrap the
critical section in an explicit BEGIN IMMEDIATE transaction - at
the file level that blocks every other writer.
Not provided
NOWAIT/SKIP LOCKED- useful for job-queue claim workflows but they add API surface, so they are added only when a real consumer needs them.
Transactions
Suprnova ships three entry points for database transactions plus nested-rollback via savepoints. Two of them - the closure form and the retry-on-deadlock helper - install an ambient context so model operations inside the closure auto-route through the transaction without callers threading a handle through every call site.
Closure form - DB::transaction
The closure form is the common case. The closure receives a
&Transaction it can use to checkpoint with savepoint(name);
every Model::* / Builder::* operation inside the closure
auto-routes through the transaction: the closure's task carries it as
the ambient transaction.
use ;
DBtransaction.await?;
- Closure returns
Ok→ commit. - Closure returns
Err→ rollback (original error propagates). - Closure panics → rollback (the in-flight transaction is dropped
on unwind; SeaORM's
DatabaseTransaction::droprolls back).
Reads inside the closure see writes from the same transaction
(every leaf SQL call looks up the ambient transaction). The first
DB::transaction call after process start picks the database
backend off DB::connection(); subsequent calls reuse the same
connection registry.
The signature uses a higher-ranked trait bound + Pin<Box<dyn Future>> so closures can borrow tx across .await points:
DBtransaction.await?;
The Box::pin(async move { ... }) shape is the cost of letting
the future use &tx after an .await - without it, the lifetime
of the borrow can't escape the closure body. Mirrors SeaORM's
TransactionTrait::transaction signature.
Savepoints - tx.savepoint(name) / tx.rollback_to(name)
Savepoints checkpoint the transaction so you can drop a block of
inner work without aborting the outer commit. Works on all three
backends - SQLite's SAVEPOINT is fully functional even though
SQLite has no row-level locking.
DBtransaction.await?;
The savepoint name is interpolated verbatim into the SQL - use a static identifier, do not splice user input.
Nested DB::transaction is rejected at runtime
DBtransaction.await?;
SeaORM's DatabaseConnection::begin() doesn't compose - calling
it on a connection that's already holding a transaction starts a
brand-new physical transaction that commits / rolls back
independently of the outer scope. That's a silent data-integrity
footgun, so DB::transaction checks for an ambient transaction up front and
returns a database error instead of producing the wrong
semantics. Use tx.savepoint(name) for nested behaviour.
Retry-on-deadlock - DB::transaction_with_attempts
Postgres SERIALIZABLE reads and MySQL row-level locks can raise
serialization-failure / deadlock errors that resolve by retrying
the transaction. transaction_with_attempts runs the closure
from scratch each time, up to attempts:
DBtransaction_with_attempts.await?;
Detection is by Display-string substring against the inner error:
- Postgres SQLSTATE
40001(serialization_failure) - Postgres SQLSTATE
40P01(deadlock_detected) - Case-insensitive
"deadlock"substring (covers MySQLDeadlock found when trying to get lockand any user-surfaced deadlock string)
On the final attempt the error propagates unchanged. The closure
runs from scratch on every attempt - capture owned state or
Arcs rather than &mut references so the retry path is
well-defined.
Caveat: because detection includes a case-insensitive
"deadlock"substring (needed for MySQL whose driver doesn't surface a SQLSTATE), any inner error whoseDisplaycontains the word will trigger a retry. When raising your own errors from inside atransaction_with_attemptsclosure, avoid "deadlock" in the message - otherwise an unrelated validation error retries up toattemptstimes before propagating. The Postgres SQLSTATE matches (40001/40P01) are the reliable signal; the heuristic is for MySQL only.
Manual form - DB::begin_transaction + *_with_tx shims
When the transaction lifetime doesn't fit a closure (e.g. spans
multiple control-flow branches), open a manual Transaction and
opt each operation into it explicitly:
let tx = DBbegin_transaction.await?;
let mut user = query
.filter
.with_tx
.first_or_fail
.await?;
user.balance = 500;
user.save_with_tx.await?;
if some_condition
tx.commit.await?; // or tx.rollback().await?;
Manual mode does not install an ambient transaction. Scope individual
operations through the transaction with Builder::with_tx(&tx)
or the Model::*_with_tx(&tx, ...) shims:
| Trait method | Manual variant |
|---|---|
Model::create |
Model::create_with_tx(&tx, attrs) |
Model::save |
Model::save_with_tx(&tx) |
Model::update |
Model::update_with_tx(&tx, attrs) |
Model::delete |
Model::delete_with_tx(&tx) |
Model::force_delete |
Model::force_delete_with_tx(&tx) |
Builder::* |
Builder::with_tx(&tx).* |
Holding a Transaction pins one pool connection for the entire
lifetime of the handle. On SQLite the pool has a single connection,
so any parallel non-transactional read against the same database
blocks until the transaction completes - load any pre-flight
rows BEFORE DB::begin_transaction() and route every dependent
write through the returned tx.
Transaction::commit / Transaction::rollback consume the
handle and require Arc::try_unwrap of the inner SeaORM
transaction; if any TxHandle clones (from tx.handle() /
Builder::with_tx(&tx)) are still alive at commit / rollback
time, both fail with a "TxHandle clones still alive" error. The
correct fix is to drop your Builder<M> / outstanding handles
before calling commit - the framework refuses to race a
half-uncommitted write against a parallel writer holding the same
tx.
Precedence
Three-way precedence for routing an operation through a connection:
- Builder-level override -
Builder::with_tx(&tx)or anyModel::*_with_tx(&tx, ...)shim. Explicit beats ambient. The eager loads of such a query read through the same transaction, and those of anon(name)query read from that connection, unless the related model declares a connection of its own. - The ambient transaction - installed by
DB::transaction/DB::transaction_with_attemptsfor the closure's task scope. A read that names another connection, throughon(name)or a model's declared connection, still runs on that connection: the transaction is pinned to one database, so a read cannot join it there, and rerouting it would change which database the code reads from (audit finding ASTRA-06, 2026-09-13). Writes stay on the transaction, since atomicity must not split across connections. - Pool fallback -
DB::connection()returns the globalDbConnectionsingleton.
Inside DB::transaction(|tx| ...), calling
Builder::with_tx(&other_tx) explicitly routes that one query
through other_tx - bypassing the ambient transaction. That's
almost certainly a bug; the override path exists for the manual
form, not for overriding the closure's own tx.
with_tx and global scopes
A builder carrying a tx_override still respects global scopes,
named scopes, and the eager-load plan - the override only changes
the connection routing, not the SQL.
Relations and DDL inside a transaction
- Relation eager loads -
Builder::with(["posts"])andCollection::load(["posts"])run theirIN (...)sub-queries through the active transaction, so pending writes inside aDB::transactionclosure are visible to relations loaded there. - DDL -
DB::statement(...)inside a transaction runs the DDL against the transaction's connection, which Postgres allows; MySQL and MariaDB commit implicitly on DDL, so DDL is unsupported inside a Suprnova transaction on those engines (the same caveat as Laravel'sDB::transaction).
Scopes
Suprnova ships two flavours of scope, mirroring Laravel:
- Local scopes - extension methods on the builder, declared per
model with
#[suprnova::scopes(Model)]. Each free function in the annotatedimplblock becomes bothModel::name()(a static starter) andBuilder::name()(a chainable method). - Global scopes - implementations of
GlobalScope<M>registered at boot viaScopeRegistry::register::<M, _>(scope). EveryModel::query()call layers them on automatically.
Local scopes
Declare local scopes by giving them the shape
fn(query: Builder<Self>, args...) -> Builder<Self>:
// Use as either a starter or a chainable method:
let active_users = active.get.await?;
let popular_users = query.active.popular.get.await?;
A method that does not handle a Builder passes through unchanged, so
scopes and ordinary methods can share one impl block.
A method that takes or returns a Builder in any other form is a scope
written wrong, and the macro refuses it with a compile error that names
the accepted shape. This covers a first parameter of &mut Builder<User>
or &Builder<Self>, a model name in place of Self (Builder<User>),
and a missing Builder<Self> return type. Left alone, such a method would
be an ordinary method and no .name() would reach the builder. Put
#[not_scope] on a helper that handles a builder and is not a scope:
Global scopes
Global scopes apply on every Model::query() call. The classic use
case is multi-tenancy - every read is scoped to the current tenant
without each caller threading the filter through.
use ;
;
// At boot - typically inside your provider/bootstrap module:
;
// Every read is auto-scoped to the active tenant:
let scoped = query.get.await?;
Multiple scopes per model compose in registration order - first registered runs first, so its filter clauses appear first in the WHERE chain. AND-combined filters don't care about order, but left-to-right matters for any clause whose side-effect order is visible (e.g. ordering, having, raw fragments).
You call apply_scopes() to keep a builder with its registered scopes and
soft-delete filter already applied. You inspect the resulting SQL or run
the same builder. Repeated calls keep each scope's clauses once. You set
without_global_scopes() or with_trashed() before applying scopes when
you need those opt-outs.
let query = query.apply_scopes;
let sql = query.to_sql;
let rows = query.get.await?;
Opting out of a global scope
Each model the #[suprnova::model] macro touches gets two static
helpers emitted on it:
// Bypass exactly one registered scope by type. Other scopes still apply.
let all_tenants = .get.await?;
// Bypass every registered scope. Admin tooling pattern.
let everything = without_global_scopes.get.await?;
The opt-outs also chain on a Builder:
Model::query().without_global_scope::<S>() and
Model::query().without_global_scopes() return the same rows as the
static helpers, wherever you write them in the chain.
The builder applies global scopes when the query runs, not when
Model::query() builds it. The statement reads <scopes> AND <your terms>. Your own terms stay together after the scopes: an or_where
never widens past a scope. update_all, delete_all, and
increment_each resolve scopes the same way, so a mass write reaches
only the rows a read returns. A scope that reads per-request state reads
it when the query runs.
Where global scopes apply
| Path | Global scopes apply? |
|---|---|
Model::query() |
Yes - the canonical scoped entry point |
Model::without_global_scope::<S>() |
Yes, minus S |
Model::without_global_scopes() |
No |
Model::query().without_global_scope::<S>() |
Yes, minus S, wherever it is chained |
Model::with_trashed() / Model::only_trashed() |
Yes - only the soft-delete filter is lifted |
Route binding (post: Post, RouteParam<Post>) |
Yes - the bound row is read through Model::query() |
Model::find(id) |
No - PK lookup goes through SeaORM directly |
Model::find_many([...]) |
No - same reason |
Model::all() |
No - same reason |
This mirrors Laravel: Eloquent\Model::find doesn't trigger
addGlobalScopes. Callers that want scoped PK lookups use
Self::query().filter("id", pk).first().await?.
Soft deletes and global scopes coexist
#[suprnova::model(soft_deletes)] installs the
deleted_at IS NULL filter via a separate string-tag mechanism, not
through the typed scope registry. Both layers compose:
Model::query()filters out trashed rows AND runs every registered scope.Model::without_global_scopes()drops registered scopes but preserves the soft-delete filter - admin tooling that wants to read every column-set still excludes trashed rows by default.Model::with_trashed()andModel::only_trashed()lift only the soft-delete filter. Every registered scope still runs, so a tenant scope keeps trashed reads inside the tenant, as Laravel'swithTrashed()does.
Relationships
Suprnova ships every Eloquent relation flavour. They're declared in
the relations = { ... } block on #[suprnova::model], and the
macro emits - per declared relation - a method on the struct, a
loaded-accessor (<name>_loaded()), a count-accessor
(<name>_count()), and the dispatcher arm the eager loader calls
into. This section covers the per-kind shape and option table; the
deep dive on join-key resolution, the morph registry, pivot rows,
and the polymorphic enum lowering lives in
Eloquent Relationships. The relation
kinds:
| Kind | One/many | Across families | Backed by |
|---|---|---|---|
HasOne<R> |
one | no | IN query on <parent>_id |
BelongsTo<R> |
one | no | IN query on FK on this row |
HasMany<R> |
many | no | same as HasOne, returns Vec<R> |
BelongsToMany<R, P> |
many | no | pivot table P, INNER JOIN + pivot::<P>() |
HasOneThrough<B, R> |
one | no | two-query JOIN parent → B → R |
HasManyThrough<B, R> |
many | no | same as above, returns Vec<R> |
MorphOne<R> |
one | yes | IN + <name>_type = "<self>" filter |
MorphMany<R> |
many | yes | same as MorphOne, returns Vec<R> |
MorphTo |
one | yes (children → many families) | per-family enum emitted at the declaration site |
MorphToMany<R, P> |
many | yes | polymorphic m2m pivot P |
MorphedByMany<R, P> |
many | yes (inverse) | same pivot, scanned the other way |
relations = { ... } syntax
Every relation declaration carries the same outer shape: the relation
name, the kind, the related type (and pivot/intermediate types where
applicable), and a { ... } block of options.
use model;
Common options:
| Option | Relation kinds | Purpose |
|---|---|---|
fk = "..." |
every kind with a child FK | Column on the CHILD pointing at the parent. Default = <snake(parent_struct)>_id. |
lk = "..." |
one/many kinds | Column on the PARENT used as the join key. Default = the parent model's primary key. |
related_key = "..." |
BelongsToMany, MorphToMany |
The related-side COLUMN the pivot's related key holds. Default = the related model's primary key. |
with_pivot = ["...", ...] |
BelongsToMany, MorphToMany |
Extra columns on the pivot to surface in the join. |
with_timestamps |
BelongsToMany, MorphToMany |
Stamp created_at / updated_at on attach/sync. |
with_default = || { ... } |
BelongsTo |
Closure producing a default when the FK is null OR the parent is missing. |
first_key, second_key, second_local_key |
HasOneThrough, HasManyThrough |
JOIN key overrides - see the Through section below. |
name = "..." |
every morph kind | Morph family name (e.g. "commentable", "taggable"). Drives the <name>_id / <name>_type columns on the child/pivot. |
targets = [T1, T2, ...] |
MorphTo |
The list of concrete morph targets. The macro emits a <Name>Morph enum at the declaration site with one variant per target plus Unknown(String, serde_json::Value). |
target_morph_type = "..." |
MorphedByMany |
The morph-type string identifying the target family on the pivot. |
pivot_table, pivot_foreign_key, pivot_related_key |
BelongsToMany, MorphToMany |
Pivot-side column / table overrides when the defaults don't fit. |
Why Suprnova diverges: default foreign key names
A default foreign key column always ends in _id. Laravel ends it in
the parent model's primary key name instead (getForeignKey() is the
snake-cased class name, _, and getKeyName()), so the two agree for
the usual id key and differ for any other. BelongsTo differs in the
name as well: Laravel takes it from the relation, Suprnova from the
target model.
| Relation | Suprnova default | Laravel default |
|---|---|---|
HasOne, HasMany on a parent keyed by uuid |
user_id |
user_uuid |
BelongsTo declared as author: BelongsTo<User> |
user_id (the target model) |
author_id (the relation name) |
BelongsToMany pivot keys |
<snake(model)>_id |
<snake(model)>_<primary key> |
Through first_key / second_key |
<snake(model)>_id |
<snake(model)>_<primary key> |
Most schemas name the column <model>_id whatever the parent's key is
called, as Laravel's own $table->foreignUuid('user_id') does, so the
default matches the column you would write. The join column on
the other side already follows the model: lk, related_key and the
owner key default to the model's primary key. When you mirror a
Laravel schema whose columns follow Laravel's rule, name them with
fk = "...", pivot_foreign_key, pivot_related_key, first_key or
second_key.
HasOne<R> and BelongsTo<R>
One-to-one in both directions. HasOne lives on the parent side and
calls R::query().filter(<fk>, <self.id>).first(). BelongsTo lives
on the child side and reads the FK off self, then calls
R::query().filter(<owner_key>, <fk_value>).first().
let user = find.await?.unwrap;
let profile: = user.profile.first.await?;
let profile = find.await?.unwrap;
let owner: = profile.user.first.await?;
BelongsTo supports with_default = || R { ... }, which fires
either when the FK is null OR when the parent row is missing. The
default closure runs per call (and per eager-loaded row) - perfect
for an empty stand-in when a deleted user still has comments:
let c = find.await?.unwrap;
// Always Some - the default fires when the user row is missing.
let author = c.author.first.await?.unwrap;
HasMany<R>
One-to-many on the parent side. Returns a fluent builder; chain filter / order / latest / take / get / count and terminate.
let u = find.await?.unwrap;
// Every post by this user, default ordering:
let posts: = u.posts.get.await?;
// Filtered + ordered + paged:
let recent = u.posts
.filter
.latest // ORDER BY created_at DESC
.take
.get
.await?;
// COUNT alone - no row fetching:
let total: i64 = u.posts.count.await?;
Available terminal methods: .first(), .get(), .count(). Available
chainable filters: .filter / .db_where, .filter_in / .where_in,
.order_by, .latest, .oldest, .limit, .take.
BelongsToMany<R, P> - first-class Pivot
Many-to-many through a #[suprnova::model]-declared pivot. The pivot
is a first-class model with its own row identity - not a tuple, not a
hidden hash map. Two key benefits over Laravel's anonymous-pivot
shape:
- The pivot row is type-safe. Read
with_pivotcolumns viar.pivot::<P>().<column>, never viar.pivot.get("..."). - The pivot model is reachable from the rest of the framework (factories, scopes, casts, hooks) the same way every model is.
let u = find.await?.unwrap;
let admin = create.await?;
// Attach + sync mutators
u.roles.attach.await?;
u.roles.attach_with.await?;
u.roles.sync.await?;
u.roles.sync_without_detaching.await?;
u.roles.detach.await?;
// Read pivot data through the per-row downcast accessor:
let roles = u.roles.get.await?;
for r in &roles
.attach(id)- INSERT a single pivot row. Errors on duplicate unless your pivot allows it (the framework doesn't dedupe at the Rust layer; use.syncfor idempotency)..attach_with(id, attrs! { ... })- INSERT with extra pivot columns. Stamps timestamps whenwith_timestampsis on..detach(id)- DELETE the pivot row(s) linking parent → id..sync([ids...])- diff-and-apply: attach what's new, detach what's missing, update supplied pivot columns on existing rows. You receiveSyncChangeswithattached,detachedandupdatedid lists..sync_without_detaching([ids...])- attach what's new and keep every existing row. You can supply(id, attrs! { ... })records to update existing pivot columns. Unchanged rows keep their timestamps, and the returnedSyncChanges.detachedis empty.
You pass ids alone or (id, Attrs) records to either sync method.
Only rows whose supplied columns change appear under updated. Pivot
filters constrain the current set, updates and detachments; rows outside
that set remain unchanged. All changes run in one transaction. You call
.touch_parent() to update the parent's timestamp when sync changes the
relation; the touch commits or rolls back with the pivot writes.
Each id and extra column binds by the type its column has: the pivot
model's field, or else the key the column holds. On Postgres and SQLite,
a u64 above i64::MAX written to the pivot fails with an error that
names the column, and nothing is sent. detach of such an id deletes
nothing on Postgres, and on SQLite it compares the id's digits, as it does
a literal. On MySQL an unsigned pivot column holds the whole u64 range,
and sync and .get() read its ids back.
.get() returns Vec<R> with the pivot stamped on each row's
internal __pivot field. The .pivot::<P>() accessor downcasts the
Arc<dyn Any> to the pivot type you declared. Calling it with the
wrong type panics - match the type to the declared pivot.
HasOneThrough<B, R> and HasManyThrough<B, R>
Reach a final target R through an intermediate B. Useful when the
relation traverses two tables but you don't need to expose the
intermediate (A → B → R).
let c = find.await?.unwrap;
let posts: = c.posts.get.await?;
The dispatcher infers JOIN keys from struct names. Overrides:
| Option | Default | Description |
|---|---|---|
first_key |
<snake(parent_struct)>_id |
Column on intermediate B pointing at parent A. |
second_key |
<snake(intermediate_struct)>_id |
Column on final R pointing at intermediate B. |
second_local_key |
"id" |
Column on intermediate B matched by second_key. Required when B uses a non-id PK. |
The parent's primary-key column is read from the model's primary_key
declaration (defaulting to "id") - there is no local_key override
on HasManyThrough / HasOneThrough; change the parent's PK via the
#[suprnova::model] attribute if you need a non-id parent key.
MorphTo with targets = [...] and per-family enum
Polymorphic relations point a child row at one of several parent
families. The child carries a (<name>_id, <name>_type) pair; the
*_type column holds the morph-type string each parent declares.
MorphTo lives on the child. Its declaration lists every parent
family it can point at via targets = [...]. The macro emits a
per-family enum named <RelationName>Morph (matching the relation
name's PascalCase form, suffixed with Morph) with one variant per
target type plus Unknown(String, serde_json::Value) for legacy rows whose
<name>_type value doesn't match any registered target. The second field
is the value of <name>_id as JSON: a number for an integer key, a string
for a String, UUID or ULID key, null for a null column.
let c = find.await?.unwrap;
match c.commentable.get.await?
The morph_type = "..." attribute on each target struct is what the
loader writes into the child's <name>_type column on insert and
filters by on read. Without morph_type, the framework derives the
type-string from to_snake(struct_name).
MorphTo dispatch - how the per-family enum picks the right variant -
consults the runtime morph registry (the inventory populated by
every #[suprnova::model(morph_type = "...")] declaration). For each
declared target, the fetch helper looks up the target's TypeId,
reads the registered morph_type string, and compares it against the
stored <name>_type value on the child row. First match wins, in
declaration order. Targets without an explicit morph_type attribute
fall back to to_snake(target_type_name) - the same default the
parent-side MorphMany / MorphOne uses to stamp the type-string at
write time, so the two sides stay aligned. This means custom
morph_type values (e.g. morph_type = "blog_post" on a struct
named Post, or any non-conventional string) dispatch correctly
without changes to the declaration site.
MorphOne<R> and MorphMany<R> - parent side
The inverse direction of MorphTo: a parent type declares the
polymorphic one-or-many it owns. MorphOne returns Option<R> from
.first(); MorphMany returns Vec<R> from .get(). Both filter
the child's (<name>_id, <name>_type) pair by self.id and the
parent's morph_type.
let post = find.await?.unwrap;
let post_comments: = post.comments.get.await?;
let post_cover: = post.cover.first.await?;
let video = find.await?.unwrap;
let video_comments: = video.comments.get.await?;
// post.comments() returns only `commentable_type = "post"` rows;
// video.comments() returns only `commentable_type = "video"`.
The same chainable surface as HasMany / HasOne: .filter /
.db_where, .order_by / .latest / .oldest, .limit / .take,
.first / .get / .count.
MorphToMany<R, P> and MorphedByMany<R, P>
Polymorphic many-to-many. The shared pivot P carries the FK pair
PLUS a <name>_type discriminator column. One end declares
MorphToMany (e.g. Post.tags(), Video.tags()), the other end
declares one MorphedByMany per target family (e.g. Tag.posts(),
Tag.videos()).
// Inverse: Tag declares one MorphedByMany per target family.
let post = find.await?.unwrap;
let video = find.await?.unwrap;
let tag = create.await?;
// `attach` / `attach_with` / `detach` / `sync` / `sync_without_detaching`
// work the same way as BelongsToMany. The `<name>_type` column lands automatically from
// the calling parent's `morph_type`.
post.tags.attach.await?;
video.tags.attach.await?; // independent attachment
post.tags.sync.await?;
// Inverse direction - Tag splits by family:
let posts_with_tag: = tag.posts.get.await?; // typed "post"
let videos_with_tag: = tag.videos.get.await?; // typed "video"
MorphedByMany's target_morph_type is required because the macro
at Tag's declaration site can't introspect the target's
morph_type = "..." attribute (it lives in a separate
#[suprnova::model] invocation). Setting it explicitly keeps each
MorphedByMany arm honest about which family it scans.
Escape hatch: hand-written relation methods
The relations declared in relations = { ... } are the only ones the
eager-load dispatcher (and with, with_count, etc.) knows about.
If a relation is too unusual for the macro shape - for example a
query that aggregates across two pivots, or a typed view of a
denormalised cache table - you can omit it from relations = { ... }
and write a plain inherent impl:
Such methods lose eager-load support - User::with(["posts_touched"])
will error because the dispatcher has no arm for posts_touched. The
in-macro declarations remain the path the framework knows how to
eager-load, count, aggregate, and predicate-filter.
Polymorphic keys and nested loads
- Morph IDs follow the target's primary key. A
MorphTotarget can declare ani64,String, UUID or ULID primary key, and the child table's<name>_idcolumn takes the same type. All targets of oneMorphTorelation share one key type. A relation that mixes key types, or a child whose<name>_idfield has another type than the key, is a compile error. - The parent side is not checked.
MorphManyandMorphOneare not compared with the child's<name>_idfield. Keep that field at the key type of every parent that owns it. MorphTo::morph_idis aserde_json::Value. So is the id in theUnknownvariant. Callas_i64()oras_str()to read it as a number or a string.- Lazy and eager reads treat global scopes differently. The lazy
comment.commentable().get()finds the target by its key and applies no global scope of the target. The eagerwith(["commentable"])runs the query of the target and applies its global scopes, so a target that a scope hides comes back asUnknown. - Nested eager loading through
MorphTo. A dotted path likewith(["commentable.user"])loadsuseron every morph target, one query per target type. The loader groups the targets by type and loads the rest of the path once for each type that is present.
// Comments on posts and on videos; both models declare `user`.
// 1 query for comments + 1 for posts + 1 for videos
// + 2 for the users (one per target type) = 5 queries,
// however many comments there are.
let comments = query
.with
.get
.await?;
Every target of the relation must declare the next relation of the path. A target that does not is an error that names the type and the relation. The check covers all targets, so it fails also when no row of that type is loaded, and when no row is loaded at all.
Eager loading
Eager loading avoids N+1 queries. Instead of posts.len() queries to
fetch every user's posts, Suprnova issues ONE query per top-level
relation regardless of how many parent rows are loaded.
To find the reads that should be eager loads, see Preventing lazy loading.
The full surface - flat list, nested paths, count, aggregates, and
predicate-filtered eager loads - is reached through the
#[suprnova::model]-emitted helpers on each model:
// Single relation:
let users = with.get.await?;
for u in &users
// Multiple relations:
let users = with.get.await?;
// Nested paths - three queries (users + posts + comments), no N+1:
let users = with.get.await?;
let p1 = users.posts_loaded;
let comments = p1.comments_loaded;
// Deeper nesting works as expected:
let users = with.get.await?;
// Count alongside the parent rows:
let users = with_count.get.await?;
for u in &users
// Aggregates - Sum / Avg / Min / Max over a relation column. The
// ergonomic read is the macro-emitted `<rel>_sum_of(col)` accessor.
let users = with_sum.get.await?;
let sum: f64 = users
.posts_sum_of
.expect;
// Multiple aggregates on the same relation compose - the cache key
// is the wide `<rel>_<kind>_<col>` form, so distinct kinds and
// distinct columns don't collide:
let users = with_sum
.with_avg
.with_min
.get
.await?;
let u = &users;
let sum = u.posts_sum_of.unwrap; // Some(_) - sum of views
let avg = u.posts_avg_of.unwrap; // Some(_) - avg of views
let min = u.posts_min_of.unwrap; // Some(Some(_)) - non-empty group
let max = u.posts_max_of; // None - with_max was not called
// Filter the eager-loaded children. The macro emits a typed
// `with_where_<rel>(closure)` static helper per relation so the closure
// parameter type is inferred - no need to spell out `Builder<Post>`:
let users = with_where_posts
.get
.await?;
// The returned `Builder<User>` chains with any other base-query
// builder method:
let users = with_where_posts
.filter
.get
.await?;
// The generic form is still available - useful when the relation name
// is computed at runtime - but you'll need to name the target type on
// the closure:
let users = query
.with_where
.get
.await?;
// Each u.posts_loaded() contains only published posts.
Cache layout
The per-row __eager cache cells are keyed by:
<rel>(relation NAME alone) forwithandwith_count. The rows and the count are kept in separate cells, sowith(["posts"])andwith_count(["posts"])on one query keep both, and a count alone does not count as loaded rows forload_missing.<rel>_<kind>_<col>(e.g.posts_sum_views) for the four aggregate kinds -with_sum/with_avg/with_min/with_max. This wide key lets multiple aggregates on the same relation coexist on the same row without overwriting each other.
| Method | Cache key | Cache cell type | Empty-group value |
|---|---|---|---|
with(["posts"]) |
posts |
Vec<Post> |
Vec::new() |
with(["profile"]) |
profile |
Option<Profile> |
None |
with_count(["posts"]) |
posts |
u64 |
0 |
with_sum(("posts","views")) |
posts_sum_views |
f64 |
0.0 |
with_avg(("posts","views")) |
posts_avg_views |
f64 |
0.0 |
with_min(("posts","id")) |
posts_min_id |
Option<f64> |
None |
with_max(("posts","id")) |
posts_max_id |
Option<f64> |
None |
The macro emits matching accessors on each model:
<rel>_loaded()- for collection relations:&[Post](panics if the relation wasn't eager-loaded). For single-value relations:Option<&Profile>.<rel>_count()-u64. Panics ifwith_count(["..."])wasn't called.<rel>_sum_of(col)/<rel>_avg_of(col)- returnOption<f64>(Noneif the matchingwith_sum/with_avgwas not called).<rel>_min_of(col)/<rel>_max_of(col)- returnOption<Option<f64>>: outerOptionis "waswith_min/with_maxcalled?", innerOptionis "is there a numeric minimum?". It isNonewhen the group was empty or the minimum is not a number, such as a date.<rel>_min_as::<T>(col)/<rel>_max_as::<T>(col)- returnOption<T>: the minimum or maximum read asT, whatever the column's type, as Laravel'swithMax('posts', 'created_at')attribute holds it. A date or a time reads from its ISO 8601 text, soTcan be the chrono type of the column. Anumeric/DECIMALminimum on Postgres or MySQL reads exactly asrust_decimal::Decimal, as the nearest value asf64, and as its decimal text asString.Nonewhen the call was not made, the group was empty, or the value doesn't read asT.
The accessors are the ergonomic surface - read through them rather
than reaching into __eager.get_aggregate::<T>(...) directly. They
build the same cache key under the hood via
eloquent::relations::aggregate_cache_key.
Composing aggregates on the same relation
The wide cache key means you can stack as many with_* calls on the
same relation in one query as you want - no collisions:
let users = with_sum
.with_avg
.with_min
.with_max
.get
.await?;
let u = &users;
let total_views: f64 = u.posts_sum_of.unwrap;
let avg_views: f64 = u.posts_avg_of.unwrap;
// Min/Max are double-Option because SQL min/max NULLs on empty:
match u.posts_min_of
// Accessor returns `None` when the matching `with_*` was skipped:
assert!; // never called with col="score"
// The latest post's date, as Laravel's withMax('posts', 'created_at'):
let users = with_max.get.await?;
let latest: = users.posts_max_as;
Aggregates and INTEGER columns
SUM over an INTEGER column lands in the cache as f64. The database
chooses the type of an aggregate: an integer of the column's width, a
real, or numeric / DECIMAL (Postgres and MySQL sum and average
integers that way). The dispatcher reads whichever arrives, and a value
that is not a number, such as the maximum of a date column, is kept for
<rel>_max_as rather than failing the query. Read via the
macro-emitted accessors regardless of the source column type.
with_where predicate routing
User::with_where_posts(|q| q.filter("published", true)) applies a
closure to the inner Builder<Post> BEFORE the
filter_in(<fk>, parent_ids) IN-query is issued, so only matching
child rows reach the cache. The macro emits one typed
with_where_<rel> static helper per declared relation, so the closure's
parameter type is inferred from the method signature.
The generic
with_where(("posts", |q: Builder<Post>| q.filter("published", true)))
is still available - useful when the relation name is computed at
runtime, or when you already hold a Builder<User> and want to attach
a predicate. It requires naming the target type on the closure because
the predicate goes through a Box<dyn Any> and Rust can't infer the
type from the relation name alone. (Rust's orphan rules forbid the
macro from adding a typed method directly on Builder<User>, so the
typed shorthand is offered only on the model - User::with_where_<rel> -
not as a builder-chain method.)
For the polymorphic kinds, the predicate runs against the related-table query - not the pivot scan.
with_where is supported on every relation kind EXCEPT MorphTo.
MorphTo's per-family enum erases the child type, so no single
Builder<R> covers all variants. Nested eager loading through
MorphTo does work: with(["commentable.user"]) groups the loaded
targets by type and loads user once per type.
Collection::load / load_missing
When you've already fetched rows and want to eager-load relations after the fact:
use Collection;
let mut users: = all.await?.into;
users.load.await?;
load_missing is per-row: each row in the collection is partitioned
independently. Rows that already have the named relation cached stay
untouched; rows that don't get the relation loaded. Mirrors Laravel's
$collection->loadMissing(...) semantics.
For nested paths the partition repeats at every level. Given
load_missing(["posts.comments"]):
- Rows without
postscached get the FULL path loaded -postsplus theircomments. - Rows WITH
postsalready cached recurse into the cached posts and loadcommentsonly on the posts that don't already have comments cached.
The same per-row partition repeats at every further segment of a
longer dotted path ("posts.comments.author" etc.) - at each step
only the rows missing that segment get the bulk-load.
load / load_missing on one model
When you hold one model, call the same methods on it. They are methods of
the Model trait, so they work on any model and through an M: Model
bound:
use Model;
let mut post = find_or_fail.await?;
post.load.await?;
post.load_missing.await?;
The relations land in the model's own relation cache. Read them with the
<relation>_loaded() accessors, which do not query again.
Both methods take the same relation names as the collection methods,
including dotted names, and run the same loader, so each relation costs
one query. Inside a DB::transaction closure the reads go through the
transaction.
load loads a relation again when the model already has it.
load_missing runs no query for a relation the model has. It checks a
dotted name at every level: with comments loaded,
load_missing(["comments.user"]) loads only the users that the cached
comments lack.
Preventing lazy loading
A relation read on one model runs one query. A template that reads
post.author() for each of 50 posts runs 51 queries. Eager loading
(with, load, load_missing) reads the relation for every row with
one query. prevent_lazy_loading(true) makes the 51-query loop fail
where it is written, instead of under load in production.
Turn it on in the bootstrap of the application, outside production.
Config::is_production() reads APP_ENV (see
Configuration):
use ;
prevent_lazy_loading;
The switch is process-wide and off by default. With it off, every read behaves as if this section did not exist.
What is refused
With the switch on, a relation read is refused when both hold:
- The model came out of a query that returned more than one row: a
get,allorfind_many, a page of a paginator, a batch ofchunk,lazyorcursor, a row ofeachover a query that matches more than one row, the rows of aHasManyThroughrelation, or the related rows of an eager load that fetched more than one. - The relation is not in the model's cache, because it was not loaded
with
with,loadorload_missing.
The reads are get() and first() of every relation kind. A refused
read runs no query and returns an error that names the model and the
relation and says to use with or load. The error holds no key and no
value of the row.
These reads are not refused:
- A model from
find,first, agetthat returned one row, orcreate. Its relations load lazily, as in Laravel. - A model you build in the process, and a replica from
replicate. - A relation that is loaded.
count()of a relation. It loads no model. Its eager form iswith_count.attach,detachandsync, which write.
The last batch of a chunk walk that holds one row is not marked, in the
same way that a get of one row is not.
prevent_lazy_loading;
let posts = query.get.await?; // several rows
for post in &posts
let posts = with.get.await?;
for post in &posts
Logging instead of failing
handle_lazy_loading_violation registers one handler for the process.
With a handler, a violation calls it and the read goes on and runs its
query. A staging system can log the reads and keep serving:
use ;
prevent_lazy_loading;
handle_lazy_loading_violation?;
LazyLoadingViolation has two fields: model, the struct name of the
model (such as "Post"), and relation, the relation name as
relations = { ... } declares it (such as "author"). Both are
&'static str. The handler runs with no framework lock held, so a
handler that panics does not break the next read.
clear_lazy_loading_violation_handler() removes the handler, so a
violation is an error again. preventing_lazy_loading() returns whether
the switch is on.
Why Suprnova diverges
The rule is Laravel's preventLazyLoading. Two behaviours differ:
refresh()replaces the row with a freshfind, so the row loses its mark. Laravel keeps it.cursor()andlazy()hydrate in batches, so their rows are marked. Laravel'scursor()hydrates one row at a time and marks nothing.
Pagination
Three paginator types compose on top of Builder<M>:
| Method | Returns | Queries per page | Use when |
|---|---|---|---|
paginate(per_page) |
LengthAwarePaginator<M> |
2 (COUNT + LIMIT) | UI needs total page count |
simple_paginate(per_page) |
Paginator<M> |
1 (LIMIT + 1) | Large tables; "Next" button only |
cursor_paginate(per_page) |
CursorPaginator<M> |
1 (LIMIT + 1) | Infinite scroll; deep pagination |
All three implement Serialize with the Laravel-standard JSON shape,
so they ship directly to Inertia / JSON consumers without reshaping.
Length-aware
use LengthAwarePaginator;
let page: = query
.filter
.order_by_desc
.paginate
.await?;
// page.data: Vec<User>
// page.total: u64 - total row count across all pages
// page.last_page: u64 - 1-based last page index
// page.current_page: u64
// page.per_page: u64
// page.from / page.to: Option<u64> - 1-based window bounds
// page.path: Option<String> - optional base URL for link generation
Page-param parsing reads ?page=N from the active request via
Context::query_param. To paginate multiple lists on the same page
with their own query keys, use paginate_using:
let posts = query.paginate_using.await?;
let comments = query.paginate_using.await?;
JSON shape (with with_path("/api/users"), page 1 of 3):
path is omitted from JSON when unset. The fields are Laravel's, and
links is the row of page links. See Pagination for the
links entries and the page URLs.
Simple paginate (no count)
paginate always runs two queries - a COUNT(*) plus the page
fetch. On large tables the count alone can dominate request time.
simple_paginate skips the count entirely; instead it fetches
per_page + 1 rows and reports whether a next page exists via the
has_more flag:
use Paginator;
let page: = query
.order_by_desc
.simple_paginate
.await?;
// page.has_more: bool - was there an extra row past per_page?
// page.current_page, page.per_page, page.data, page.path: as above.
JSON shape (with with_path("/api/users"), page 1):
Cursor paginate (keyset)
Cursor paginate is the choice for infinite scroll, deep pagination,
or anywhere a stable row order with cheap O(1)-per-page seeking is
worth more than a numeric page UI. Bidirectional - it reads the
?cursor=<opaque> query parameter, walks forward or backward by the
cursor's direction, and emits both next_cursor and prev_cursor as
the page's neighbours exist (matching Laravel's cursorPaginate()).
use CursorPaginator;
let page: = query
.cursor_paginate
.await?;
// page.data: Vec<User>
// page.per_page: u64
// page.next_cursor: Option<String> - opaque cursor for the next page (None on the last)
// page.prev_cursor: Option<String> - opaque cursor for the previous page (None on the first)
// page.path: Option<String>
Cursors are encrypted and authenticated via CursorPaginator::encode_value -
they encode the keyset boundary (each ordered column's value, the
model's primary key among them) plus a direction tag, AES-256-GCM-sealed
with the framework's APP_KEY.
Tampering produces a 400 ParamParse error; the cursor is opaque to
the client and unforgeable without the key.
The next request passes the cursor through ?cursor=<opaque>:
GET /api/users?cursor=eyJ0IjoiQmlnSW50IiwidiI6MTAwLCJkIjoibmV4dCJ9...
Cursor pagination keeps the builder's ORDER BY, as Laravel's
cursorPaginate does: latest() pages newest first, and a descending
order walks downward. The primary key, ascending, breaks ties when no
order names it, and a builder with no order walks the primary key
ascending. An order the cursor cannot carry (order_by_raw,
in_random_order) returns a 400 error. See
Pagination.
JSON shape:
next_cursor, next_page_url, prev_cursor, and prev_page_url are
always present as JSON keys (emitted as null when absent) so client
schemas can rely on the field's presence; path is omitted when unset.
A page URL is the path with the cursor in the cursor key.
Errors
| Condition | Variant | HTTP |
|---|---|---|
per_page == 0 |
FrameworkError::ParamError { param_name: "per_page" } |
400 |
| Invalid cursor (bad base64, JSON, or HMAC fails) | FrameworkError::Internal from Crypt::decrypt_string |
500 |
| Underlying DB failure | FrameworkError::Database |
500 |
Cursor authentication failure surfaces as Internal (not
ParamParse) so a tampered cursor doesn't leak protocol-level
information to the client; the response body still carries a
human-readable reason.
Reading query params outside a real request
Tests, console commands, and background workers don't run inside a
hyper request - so Context::query_param("page") returns None and
paginate falls back to page 1. Tests that need to exercise a
specific page can install a per-thread override:
use Context;
async
test_set_query / test_clear_query are gated behind the
testing feature (default-enabled in framework/Cargo.toml) so
release builds never see this surface.
Chunking and lazy iteration
Seven streaming entry points on Builder<M> let you process large
result sets in bounded memory. Pick by trade-off:
| Method | Pagination | Concurrent-safe? | Returns |
|---|---|---|---|
chunk(n, async |batch| { ... }) |
OFFSET | No | Result<(), _> |
chunk_by_id(n, async |batch| { ... }) |
PK cursor | Yes | Result<(), _> |
chunk_map(n, async |batch| { ... }) |
OFFSET | No | Collection<U> |
each(async |row| { ... }) |
OFFSET, size 1 | No | Result<(), _> |
lazy() |
PK cursor, batch 1000 | Yes | LazyCollection<M> |
lazy_by_id(batch_size) |
PK cursor, custom batch | Yes | LazyCollection<M> |
cursor() |
Alias for lazy() |
Yes | LazyCollection<M> |
chunk - OFFSET-paginated batches
use ;
query.chunk.await?;
The closure receives a Collection<M> per batch - slice-shape access
(.iter(), indexing) works directly via Deref.
chunk, chunk_map, and each keep the query's own OFFSET and
LIMIT, as Laravel's chunk does: the offset skips rows once, at the
start of the walk, and the limit caps the rows the whole walk visits.
chunk is OFFSET-paginated and not safe under concurrent inserts:
rows inserted before the next batch's offset get skipped; rows deleted
before the offset get processed twice (whatever shifted into their
slot). Use chunk_by_id for production-grade bulk processing against
tables under write load.
chunk_by_id - PK-cursor batches, concurrent-safe
query.chunk_by_id.await?;
Each batch filters on WHERE id > last_id ORDER BY id ASC LIMIT n,
so rows inserted mid-iteration with PKs above the cursor land in a
later batch (or are picked up by a subsequent run) - they never cause
an original row to skip or duplicate.
The walk sets its own order. An ORDER BY already on the query is
dropped, because any other order would make the cursor skip some rows
and repeat others. An OFFSET on the query skips that many rows once,
before the first batch; every later batch starts at the cursor. A
LIMIT on the query caps the rows the whole walk visits, as in
Laravel's chunkById: .limit(10).chunk_by_id(3, ..) hands over 3, 3,
3 and 1 rows.
The cursor is the value of the primary key, in the order of the key. These keys work:
- An integer key (
i64,i32, and the other integer types). - A text key:
String, and the keys ofunique_id = "uuid","uuid_v4"and"ulid", which the model stores as text.
For a key that grows with the creation time of its row (auto-increment, UUID v7, ULID), a row inserted mid-run lands after the cursor, and a later batch finds it. A random key (UUID v4) has no such order: a row inserted mid-run with a key below the cursor is not seen by that walk, and the next run picks it up. Every row that existed when the walk started is seen once.
chunk_by_id refuses a key that cannot carry the cursor with
FrameworkError::internal. The error names the model and the column and
never the value of the key. Two cases are refused before the first
query:
- A composite key.
- A key column that is neither an integer nor text, such as a native
uuid::Uuidcolumn, a timestamp or a decimal.
A third case is found while the walk runs. A row whose key is null, or is not of the kind of its column, ends the walk with the same error. The check runs on each batch before the closure sees it, so the closure never processes a batch that holds such a row.
A u64 key walks its whole range. On MySQL, whose unsigned keys reach
u64::MAX, that includes keys above i64::MAX; on Postgres and SQLite no
key passes i64::MAX.
For a key that chunk_by_id refuses, use chunk(). It paginates by
OFFSET, so it needs no cursor, and it has the concurrency limits above.
chunk_map - chunk + per-chunk map
let totals: = query
.chunk_map
.await?;
Maps each batch through f, concatenates the mapped output, and
returns a single Collection<U>. Memory-bounded only when U is
strictly smaller than M - pick this when you're producing summaries
(per-batch totals, ids, aggregates) rather than transformed rows.
each - one row at a time, OFFSET
query.each.await?;
Sugar for chunk(1, ...) - one query per row. For large datasets,
switch to lazy() which batches internally (default 1000 rows per
fetch) while still surfacing one row at a time to the consumer.
lazy / lazy_by_id / cursor - streams
let mut stream = query.lazy;
while let Some = stream.next.await
lazy() returns a LazyCollection<M> - a Send stream wrapper
that yields Result<M, FrameworkError> per row. Backpressure works
naturally: a slow consumer parks at the await point and the next
batch only fetches when the in-memory buffer drains.
lazy() batches via PK cursor with a default size of 1000 rows.
Override the batch size with lazy_by_id(500). cursor() is the
Laravel name and is a zero-cost alias for lazy().
lazy(), lazy_by_id() and cursor() use the same keyset cursor as
chunk_by_id, so the same keys work, the same keys are refused, and an
ORDER BY or OFFSET on the query is treated the same way. The
error is the first item of the stream. Refusal by column type happens
before the first query. A row with a null or mismatched key is refused
when its batch arrives, before the stream yields any row of that batch.
Eager loads inside chunks
All seven entry points reject .with(...) up front with a loud
FrameworkError::internal. The Builder's cross-batch clone drops
the type-erased eager-load plan (its boxed-dyn Any predicate isn't
clonable without tightening the public API), so honouring the plan
would be silently inconsistent across batches. Re-apply .with(...)
inside the per-chunk closure when needed - each batch's
Collection<M> composes with load(...) / load_missing(...):
query.chunk.await?;
Collections
Collection<T> is Suprnova's Laravel-shape collection - the return
type of Builder::get (where T is the model), of Model::all, of
pluck / chunk_map, and of every other terminal that yields more
than one row. It dereferences to &[T] so existing Vec call sites
keep working without changes; the Laravel surface is composed on top.
This section is the everyday surface; the full method index, the
generic-vs-model split, the LazyCollection<M> streaming wrapper,
and the borrow-vs-consume rules are in
Eloquent Collections.
Generic surface
Available on every Collection<T> regardless of T:
use Collection;
let nums: = from_vec;
nums.first; // Some(&3)
nums.last; // Some(&9)
nums.len; // 6
nums.is_empty; // false
nums.contains; // true
// Predicate closures receive `&&T` - note the double-deref `**n`:
nums.first_where; // Some(&4)
nums.contains_where; // true
// For a count, run the predicate inline: `nums.iter().filter(|n| **n > 2).count()` - 4
Transformations consume self and return a new Collection:
let doubled: = nums.clone.map;
let evens: = nums.clone.filter;
let chunks: = nums.clone.chunk; // [[3,1],[4,1],[5,9]]
let unique: = nums.clone.unique;
let sorted: = nums.clone.sort;
Model-aware methods on Collection<M>
When T is a model, additional string-keyed methods route through
the macro-emitted field_value(name) accessor:
let users: = query.get.await?;
let emails: = users.;
let by_role: =
users.clone.;
let active: = users.clone.where_eq;
let total: f64 = users.clone.;
let avg: f64 = users.clone.;
let max: = users.clone.;
The closure-based pluck_by is the typed alternative - useful when
the field name would otherwise require a string lookup the type
system can't check:
let names: = users.pluck_by;
Per-row field_value(name) returns Option<serde_json::Value> -
None when the column name doesn't match any declared field. Custom
casts that fail to serialise also surface as None. The string-keyed
methods silently skip those rows; the closure form short-circuits in
the closure body so the caller can decide.
Streaming via LazyCollection
For datasets too large to materialise, Builder::lazy() /
lazy_by_id(n) / cursor() return a LazyCollection<M> - a
Stream wrapper that fetches rows in PK-cursor batches. See
Chunking and lazy iteration.
Eager loading on a collection
Collection::load(["posts"]) / load_missing(["posts"]) execute
the same eager-load dispatch a Builder::with(...) chain emits,
but against an existing collection. load_missing is per-row: each
row in the collection is partitioned into "needs load" / "already
loaded" buckets and only the missing ones get the bulk-load. See
Eager loading.
Mass assignment
Fillable allowlist
create.await?;
Guarded denylist
guarded is the inverse - every field is fillable EXCEPT the
guarded ones. Mutually exclusive with fillable; using both at
once is a compile-time error from the macro.
Default policy
When neither fillable nor guarded is set, the default policy is
guarded = ["id"] (or whatever primary_key = "..." resolves to) -
every field is fillable except the primary key. This matches
Laravel's "all fields fillable except the PK" default.
unguarded(closure) escape hatch
unguarded(closure) turns off the filter for a block:
use unguarded;
// Bypass the filter for a one-shot data-migration script:
unguarded.await?;
Implementation: a tokio::task_local! boolean the Fillable::apply
filter checks before running. Task-local means concurrent requests
aren't affected by another task's unguarded scope.
Casts
Casts run at the boundary between storage (column value) and runtime
(model field). Each cast type implements the Cast trait. Built-in
casts cover Laravel's full set; users register custom casts via the
trait. This section is the quick-reference index; the full per-cast
contract - primitive, temporal, structured, enum, encrypted, hashed,
plus the casts! runtime override macro - lives in
Eloquent Casts, Accessors & Mutators.
Explicit, with two defaults
Casts are declared in #[model(casts = { ... })]. A prefs: Json field
doesn't implicitly become AsJson; you write casts = { prefs = AsJson }.
Rationale: you should be able to read the model and know exactly what runs
at storage boundaries.
Two field types get a cast without one, because they don't work without it. A field's own cast wins over both:
- A
DateTime<Utc>orOption<DateTime<Utc>>field getsAsDateTimeorAsOptionalDateTime, which store text.datetime_castin the package'sCargo.tomlswaps them for the native casts; see Native date-time casts. - A
u64orOption<u64>field, the key included, getsAsU64orAsOptionalU64. SeaORM reads au64on MySQL only; the cast reads it on Postgres and SQLite too, where it holds0toi64::MAX. For more information, see Laravel tables on MySQL.
Example
use ;
Full Laravel cast list and Suprnova mapping
| Laravel cast | Suprnova cast | Runtime type |
|---|---|---|
bool, boolean |
AsBool |
bool |
int, integer |
AsInt<I> |
I: PrimInt |
float, double, real |
AsFloat |
f64 |
decimal:N |
AsDecimal<N> |
rust_decimal::Decimal |
string |
AsString |
String |
array |
AsArray<T> |
Vec<T> (JSON-encoded) |
array (nullable column) |
AsOptionalArray<T> |
Option<Vec<T>> (None is SQL NULL) |
object |
AsObject<T> |
T: Serialize + DeserializeOwned |
object (nullable column) |
AsOptionalObject<T> |
Option<T> |
collection |
AsCollection<T> |
Collection<T> |
collection (nullable column) |
AsOptionalCollection<T> |
Option<Collection<T>> |
json |
AsJson<T> |
T (raw JSON column) |
json (nullable column) |
AsOptionalJson<T> |
Option<T> |
date, date:format |
AsDate |
chrono::NaiveDate |
datetime, datetime:format |
AsDateTime |
chrono::DateTime<Utc> |
datetime (nullable column) |
AsOptionalDateTime |
Option<chrono::DateTime<Utc>> |
datetime (native column with a zone) |
AsNativeDateTime |
chrono::DateTime<Utc> |
datetime (nullable native column with a zone) |
AsOptionalNativeDateTime |
Option<chrono::DateTime<Utc>> |
datetime (native column without a zone) |
AsNaiveDateTime |
chrono::DateTime<Utc> (stored as the UTC wall clock) |
datetime (nullable native column without a zone) |
AsOptionalNaiveDateTime |
Option<chrono::DateTime<Utc>> |
immutable_date |
AsImmutableDate |
chrono::NaiveDate |
immutable_datetime |
AsImmutableDateTime |
chrono::DateTime<Utc> |
timestamp |
AsTimestamp |
i64 (unix epoch) |
encrypted |
AsEncrypted |
String (encrypted via Crypt) |
encrypted:array |
AsEncryptedArray<T> |
Vec<T> (JSON + encrypted) |
encrypted:object |
AsEncryptedObject<T> |
T (JSON + encrypted) |
encrypted:collection |
AsEncryptedCollection<T> |
Collection<T> |
EnumClass::class |
AsEnum<E> |
E: EnumString + AsRefStr |
AsArrayObject::class |
AsArrayObject<T> |
IndexMap<String, T> |
AsArrayObject::class (nullable column) |
AsOptionalArrayObject<T> |
Option<IndexMap<String, T>> |
hashed |
AsHashed |
String (Hash::make on write; never decrypts) |
31 casts total. Most map one-to-one with Laravel. A Laravel cast passes
null through on any column; in Rust the field type says whether a
value can be missing, so the five JSON casts and three of the
date-time casts have AsOptional* forms for nullable columns. The
AsOptionalDateTime (used by soft_deletes) is auto-injected by
the macro when the soft-delete column is Option<DateTime<Utc>>.
Encrypted cast failure modes
The four AsEncrypted* casts route every encrypt/decrypt through the
Crypt facade (keyed by APP_KEY). When decryption fails - wrong
key, truncated ciphertext, tampered bytes, AEAD tag mismatch - the
cast surfaces a clear FrameworkError::Internal from
Cast::from_storage. There is no silent fallback to garbage:
- Loading a row through
Model::find/Model::query()propagates the decrypt error and (per the macro-generatedFrom<inner::Model>) panics withcast from_storage failed - corrupt data in database column. Operators see the failure in logs immediately; the model never carries plausible-but-wrong plaintext. - The
AsHashedcast is one-way; it never decrypts so this failure mode does not apply.
This matches Laravel's encrypted cast: a wrong APP_KEY against an
existing encrypted column is a hard error, never a quiet
null/empty string.
Rotating APP_KEY
Suprnova supports zero-downtime key rotation via a key ring: the
current APP_KEY encrypts; an optional APP_KEY_PREVIOUS env var
(comma-separated, oldest-to-newest) supplies decrypt fallbacks for
data written under older keys. Encryption always uses the current
key - previous keys participate only on decrypt.
Each decrypt that falls through to a previous key emits a
tracing::warn! line containing the previous-key index. The log
payload deliberately excludes plaintext and ciphertext; just the
fact-of-rotation plus an actionable re-encrypt hint.
Rotation procedure (zero-downtime, safe for production):
- Mint a new key:
suprnova key:generate(writes to stdout). - Move the old key to
APP_KEY_PREVIOUSand setAPP_KEYto the new value:APP_KEY_PREVIOUS=<old_key> APP_KEY=<new_key> - Deploy. New writes use the new key; existing rows continue to
decrypt via the previous-key fallback. Warnings in logs identify
columns that still depend on
APP_KEY_PREVIOUS. - Run a re-encrypt pass. You explicitly update each encrypted attribute:
You use an explicit update because a clean save writes no columns. The cast encrypts the supplied attribute under the current key.query.chunk.await?; - Once logs show no more
APP_KEY_PREVIOUSwarnings (give the batch + any soft-deleted / archived data a generous window), removeAPP_KEY_PREVIOUSfrom the environment and redeploy.
Multi-step rotation. If you rotate again before completing the
previous pass, append: APP_KEY_PREVIOUS=<oldest>,<previous>. The
ring tries every previous key in order. The list is capped at
8 entries - a realistic chain is 1-3 (one in-flight rotation, maybe
one stalled prior roll) and a longer list is almost always a config-
templating accident; exceeding the cap fails boot with an actionable
diagnostic rather than silently dropping a key the operator may still
depend on.
Constraints.
- A malformed entry in
APP_KEY_PREVIOUSfails boot loudly (same as a malformedAPP_KEY) - a half-rotated secret should never silently degrade. - More than 8 entries in
APP_KEY_PREVIOUSfails boot loudly - see [suprnova::crypto::MAX_PREVIOUS_KEYS]. - Empty entries in the list (e.g. trailing commas from templated config) are tolerated as "no key in this slot" - not an error.
- The wire format is unchanged from the pre-rotation single-key layout: no key identifier is embedded in the ciphertext. The ring trial-decrypts each key in order until one succeeds.
Runtime cast override - with_casts
let users = query
.with_casts
.get
.await?;
with_casts overrides the model's declared casts for the duration
of a single query - useful when a raw column comes back from a
join / view / select_raw and needs a different type coercion
than the model's default.
Custom casts
Custom casts implement Cast:
use Cast;
use FrameworkError;
;
The Cast trait is shipped alongside the primitive casts. Custom
casts can use either String storage (when JSON-encoding) or any
of the SeaORM-supported scalar types (i64, f64, bool,
Vec<u8>).
Accessors and mutators
Accessors
When user.to_array() runs (or user.to_json(), which delegates
to it), the full_name accessor is called and its return value
is inserted into the JSON output. Calling user.full_name() from
Rust is just a regular method call.
Mutators
Mutators run before storage:
Calling user.password = "secret".into() directly assigns the raw
value without running the mutator. To run the mutator path, call
user.set_password(json!("secret")) or use the JSON path
(user.fill(attrs!{password: "secret"})), which routes through
the mutator automatically because "password" is listed in
mutators = [...].
How routing works
- Serialization (
to_array→Value,to_json→String) runs accessors. Every field name listed inappends = [...]becomes a call toself.<name>(); the return value is inserted into the JSON output.to_json()is a thin wrapper:serde_json::to_string(&self.to_array()). - Fill-style writes (
fill,create,update) route through mutators. Every field name listed inmutators = [...]becomes a call toself.set_<field>(value)instead of direct assignment.
The function-level #[accessor] and #[mutator] macros emit
registry entries the macro's serialization / fill paths walk.
Malformed values are errors, not defaults
A value that cannot decode into its field's type fails the write and names the field:
let err = user.fill.unwrap_err;
// ValidationError { field: "age", message: "could not decode the
// supplied value: invalid type: string \"not a number\", expected i32" }
The model is left untouched - a rejected fill applies nothing.
Two nearby cases behave differently, on purpose:
- An unknown column is still skipped silently, matching Laravel's
$model->fill(). Not knowing about a column is not the same as being handed a broken value for one you do know. - A column excluded by
fillable/guardedis dropped by the mass-assignment filter before decoding, so a malformed value for a field the caller may not set is also silent. Erroring there would tell an unauthorised caller which columns exist.
Numeric widening is not a type error: a JSON integer decodes into an
f64 field normally.
Before v0.8.0 a malformed value was silently replaced by the field's
Defaultand the call returnedOk-fill(attrs!{ age: "abc" })setage = 0and reported success. If you were relying on that coercion, validate or convert before callingfill.
Hidden / visible
hidden = [...] is a denylist - every column except the listed
ones serialises. visible = [...] is the inclusive form - only the
listed ones serialise. Mutually exclusive at compile time.
Timestamps
When both created_at and updated_at columns exist, the macro
auto-detects them and enables timestamp tracking:
created_atis set toUtc::now()onsave()for new rows.updated_atis set toUtc::now()when a dirtysave()writes. A clean save leaves the timestamp unchanged.
The auto-detect is conservative: if the struct has only one of the
two columns, the macro errors out so a typo
(craeted_at) doesn't silently disable timestamps. Set
timestamps = false to opt out entirely.
A struct with neither column gets no timestamps by default. If you ask
for them with timestamps = true (or the bare timestamps flag), the
build fails and the error names both columns, as
#[suprnova::document] does, so a model never silently writes none.
Disabling auto-timestamps
touch() - bump updated_at without other changes
user.touch.await?;
touch() issues UPDATE table SET updated_at = ? WHERE pk = ? -
atomic, no read-modify-write. The macro emits a Touchable impl on
every timestamped model.
Parent touching
After a comment is created, saved, updated, or deleted, its post's
updated_at is bumped - one
UPDATE posts SET updated_at = ? WHERE id = ?, no SELECT. That is what
a cache key hanging off post.updated_at needs to stay honest when
only a child changed.
Every name in touches must be a BelongsTo or a MorphTo relation
declared in the same relations = { ... } block. A name that doesn't
resolve, or one that resolves to a different relation kind, is a compile
error rather than a surprise on the first save.
A MorphTo name touches the owner that the row's <name>_type and
<name>_id columns point at:
The write finds the owner after the pre-write listeners (Creating,
Saving, Updating, Deleting) have run and before the statement, from
the values the statement writes. A listener that rewrites <name>_type
or <name>_id therefore decides which owner is touched. A
<name>_type that names none of the targets makes the write fail: the
write returns Err, no row is written, no post-write event is
dispatched and no owner is touched.
An owner whose model has timestamps = false is skipped: no error,
no write, and the child's save still returns Ok. Same for an owner
reached through a NULL foreign key or a NULL <name>_id, and for a
soft-deleted owner.
The touch runs on the same executor as the write that triggered it, so
inside a DB::transaction closure it joins that transaction and a
rollback reverts it.
Why Suprnova diverges
Laravel's touchOwners loads each parent model and recurses, so a
comment save also bumps the post's own owners and fires each parent's
saved event. Suprnova resolves the parent through the relation
registry and writes the column directly - one statement per touched
relation, no hydration. The cascade is therefore one level deep and
fires no parent events. That is the trade for a save that doesn't issue
a SELECT per touched relation. Reach for an observer when you need the
grandparent bump or the event.
restore() on a soft-deleted child does not touch its owners.
Laravel's restore goes through save; Suprnova's is a direct
UPDATE deleted_at = NULL.
Format
Always ISO 8601 with UTC. No Model::$timestampsFormat override
(per the divergence-from-Eloquent table - frontend interop comes
first; locale formatting belongs in the i18n layer).
Observers and lifecycle events
Every model goes through a fixed 16-event lifecycle as it moves
through create / save / update / delete / restore /
replicate / Builder query paths. Listeners can hook each event
to log, audit, side-effect, validate, or cancel the in-flight
operation.
The 16 lifecycle events
Events split into two groups by cancellability:
Cancellable (5) - fire BEFORE the database write. A listener
returning EventResult::cancel("reason") aborts the operation with
FrameworkError::bad_request(reason).
| Event | When | Payload |
|---|---|---|
Saving |
Before both create and save |
Arc<Mutex<Attrs>> + is_creating: bool |
Creating |
Before create |
Arc<Mutex<Attrs>> |
Updating |
Before save / update on existing row |
Pre-update model snapshot + Arc<Mutex<Attrs>> |
Deleting |
Before delete (soft or hard) |
Model + is_force: bool (force-delete on soft-delete) |
Restoring |
Before restore on soft-delete model |
Model |
Non-cancellable (11) - fire AFTER the operation. Listener errors propagate but cannot stop a write that already landed.
| Event | When | Payload |
|---|---|---|
Retrieving |
Once per Builder query, before the DB call | None |
Retrieved |
Once per row returned by a Builder query | Model |
Created |
After successful create |
Model |
Updated |
After successful save / update |
Previous + current snapshots |
Saved |
After both create and save |
Model |
Deleted |
After successful delete |
Model + is_force: bool |
Trashed |
After soft-delete (NOT force-delete) | Model |
Restored |
After successful restore |
Model |
Replicating |
During replicate / replicate_except, before return (NOT replicate_into - per-source-type) |
Source + Arc<Mutex<replica>> (mutable) |
ForceDeleting |
Before force_delete on soft-delete model |
Model |
ForceDeleted |
After successful force_delete |
Model |
The cancellable / non-cancellable split mirrors Laravel's creating
vs created hook pair. Saving fires for both insert and update -
override that one when the behaviour is identical across both paths
and discriminate via is_creating.
Replicating is the one non-cancellable hook that hands a mutable
reference (the replica is Arc<Mutex<M>>). Use it to clear
timestamps, regenerate UUIDs, reset auto-increments, etc. before the
clone is returned to the caller.
Observers vs raw listeners
Two ways to hook lifecycle events:
-
Raw listeners - call
EventFacade::listen::<Created, _>(Arc::new(MyListener))for each event you want, one impl per event. This is the underlying mechanism; observers ride on top of it. -
Observers - bundle all 16 hooks under one trait. The macro sees which methods the user overrode and registers exactly those. This is the recommended path for any non-trivial set of hooks.
use async_trait;
use Attrs;
use EventResult;
use Observer;
use FrameworkError;
;
// <- MUST precede #[async_trait]
Every trait method has a default no-op, so the impl block contains only the events you care about. The macro identifies overrides by name match against the closed 16-method set; methods you don't override register no listeners.
Required attribute ordering
#[suprnova::observer(M)] MUST appear ABOVE #[async_trait]:
// outer - runs first, sees raw async fns
// inner - rewrites async fn signatures
Attribute macros expand outside-in. async_trait rewrites every
async fn into a desugared Pin<Box<dyn Future>> poll-fn shape;
if #[async_trait] ran first, the observer macro's name-match
against the 16 trait method names would find nothing and silently
emit zero listeners.
Four registration paths
| Path | When to use |
|---|---|
#[suprnova::observer(M)] (inventory) |
Static observer known at compile time. Auto-installs on boot. |
#[model(observers = [Foo, Bar])] |
Documentation + compile-time validation that the listed types resolve. Does NOT itself register. |
Model::observe(MyObs).await |
Runtime registration. Hand-driven; useful when registration depends on config. |
EventFacade::listen::<events::Created, _>(...) |
Lowest level - one event at a time. Use when an observer feels heavy. |
The observers = [...] attribute on #[model] is a documentation
marker. It compiles to a const _: fn() = || { let _ = ::std::any::type_name::<T>; ... }; block that proves each listed
type resolves to a real Rust item; typos surface at the model
declaration site. Actual install is via the inventory pathway -
the #[observer(M)] attribute on Foo is what enrolls Foo for
auto-install.
Bootstrap
Call bootstrap_observers() once at startup to drain the inventory
and install every #[observer(M)]-registered observer:
bootstrap_observers.await?;
The drain is idempotent for the inventory pathway - each observer's
install closure is gated by a per-type AtomicBool (T2b's macro
emission), so calling bootstrap_observers() twice does not
double-register.
The runtime Model::observe(MyObs) shim is NOT gated. Calling it
twice registers two listener sets, matching Laravel's manual
Model::observe(MyObs::class) semantics. If a hand-driven observer
also has #[observer], the inventory adapter fires in addition to
the manually-installed ones.
Cancelling from an observer
The five cancellable hooks return EventResult. To abort the
operation, return EventResult::cancel("reason"):
The cancel reason surfaces as FrameworkError::bad_request(reason)
from Subscription::create. The row never lands in the database -
cancel is a true abort, not a "delete after the fact".
Multiple observers may register cancellable hooks on the same model;
any one of them returning Cancel stops the operation. Order is the
inventory enrolment order (link order in practice).
Multiple observers on one model
Multiple Observer<M> impls all fire for the same event -
EventFacade dispatch fans out to every registered listener rather
than picking one:
// Comment::create(...) fires AuditObserver::created AND NotifyObserver::created.
This matches Laravel's fan-out semantics and is the load-bearing
property behind the "decompose hooks by concern" pattern: an
AuditObserver only knows about audit, a NotifyObserver only
knows about notifications, and the model declaration doesn't care
how many observers attach.
Manual Model::observe()
Every #[suprnova::model] struct gets a per-model observe<O>()
shim. Call it at boot for dynamic registration:
;
// At runtime:
observe.await;
The shim's O: Clone + 'static bound is what lets the framework
hand a fresh observer clone to each of the 16 internal adapter
listeners. All 16 listener adapters install on every call - the
trait defaults make non-overridden methods cheap no-ops.
Constraints
-
The macro version requires the impl block use plain method names matching the trait's 16 hooks. Renamed methods,
#[allow]-suppressed defaults, and#[cfg]-gated bodies fall outside the name-match and don't register listeners. -
Observer structs the macro inspects must be zero-sized (no fields). The macro constructs the observer via
let obs = MyObserver;inside each adapter. Stateful observers (carryingArc<Inner>) need the runtimeModel::observe()path, which takes the observer by value and clones it into each adapter. -
Test isolation: use unique model types per scenario. The process-global EventDispatcher means listeners installed for
Userare visible to every test in the same binary. Per-test unique model types (T2Comment,T2Subscription, …) keep cross-test bleed out of the counter assertions. Theeloquent_observers.rsintegration tests exercise this pattern.
Prunable
Laravel ships a Prunable trait that lets a model declare a scope
of rows to delete on a schedule. Suprnova mirrors that with two
traits and a console command.
Declaring a pruner
use async_trait;
use ;
use Prunable;
MassPrunable - bulk-delete variant
For high-volume tables (audit logs, request logs, expired cache
entries) MassPrunable skips per-row events and runs a single
DELETE WHERE … statement. It runs where the prunable() query routes,
the same place the --pretend count reads: the model's declared
connection, or the query's own on(name) or with_tx:
use MassPrunable;
Triggering pruning
Run it with the suprnova CLI. The CLI runs your project's console
binary, which owns the command:
cargo run --bin console -- model:prune runs the same command without the
CLI.
--model matches the last segment of the type name. When no registered
pruner has that name, the command prints
model:prune: no pruner registered for `<name>` on the error stream,
deletes nothing and exits with code 0.
Programmatically the runners are at
suprnova::eloquent::{prune_all, prune_all_dry, prune_one}.
Pruning hook
Prunable::pruning(&self) fires before each row delete so the user
can run side-effects (cleaning up associated files, fanning out
events, etc.). The default impl is empty. MassPrunable skips this
hook by definition - bulk deletes don't enumerate rows.
Cascade behavior
Pruning does NOT auto-cascade to related rows. A Prunable or
MassPrunable impl on User deletes user rows; their posts,
role_user pivot entries, polymorphic comments, etc. are LEFT
ORPHANED with FK columns pointing at the now-deleted user.
This matches Laravel's contract: relation cleanup is the user's job. Two clean ways to handle it:
-
Database-level FK cascade - declare
ON DELETE CASCADE(orON DELETE SET NULL) in the foreign-key constraint when you write the migration. The DB engine handles cascade for free, with no per-row Rust code. -
Per-row hook - implement
Prunable::pruning(&self)to delete children before the parent row is dropped. The hook fires inside the same logical operation as the parent delete, so consistent ordering is guaranteed:
MassPrunable is set-based - pruning() does not fire. Use plain
Prunable whenever you need cascade. The framework will not silently
issue a per-row DELETE when you opt into MassPrunable; the trade-off
is documented loudly.
Registry mechanism
Pruner registration uses the same inventory pattern as observers,
commands, and supervisors. The #[suprnova::prunable] attribute on
the impl Prunable for T { ... } block auto-registers via
inventory::submit! at compile time. No central config file; adding
a new prunable type is one attribute.
Multi-connection routing
Production apps regularly need more than one database connection - the canonical case is a read replica for analytics + the primary for writes, but the surface generalises to any named connection (reporting DB, archive DB, per-tenant shard).
Registering a connection
Call DB::register_named(name, config) at boot for every
non-default connection your app talks to:
DBregister_named.await?;
Two names are reserved: __primary__ short-circuits the registry
to DB::connection(), and __read_replica__ opts the connection
into automatic read-write split routing - see below.
Per-query opt-in: Model::on(name)
Model::on("reporting") returns a Builder<M> pre-set to route
through the named connection:
let totals = on
.order_by_desc
.limit
.get
.await?;
on(...) is request-scoped - it only affects the chained builder.
The next plain Order::query() call resolves through the default.
Per-model default: #[model(connection = "...")]
When a model always lives on one connection, declare the default on the attribute:
Every Event::query() / Event::create() / Event::find() call
routes through events_db without needing the per-query .on(...)
override. An explicit .on(...) on a builder still wins.
Read-write split
Registering a connection under the reserved name
__read_replica__ opts every model into automatic routing: read
methods (first / get / find / count / paginate / chunk /
the closure-driven walkers) flow through the replica; writes
(save / create / update / delete / force_delete /
replicate / attach / detach / sync / increment /
decrement) flow through the primary.
Model::on_write_connection() opts a single builder OUT of the
replica - useful when read-your-writes consistency matters
(e.g. immediately after a save, before replication catches up).
Routing precedence
Every read and every write resolves its connection in this order:
- Active transaction wins absolutely. Inside
DB::transactionevery read AND every write uses the tx connection.on(name)is IGNORED inside a transaction - the tx is bound to a specific physical connection. SeaORM can't begin a transaction on one connection and run statements against another. - Per-builder
on(name). Set viaModel::on(name)/Builder::on(name). Wins over the model default and the read/write split. Model::on_write_connection(). Forces the primary even when the operation would otherwise route to the replica.- Per-model
#[model(connection = "...")]default. Wins over the read/write split for the model's own queries. - Read/write split. When
__read_replica__is registered, read methods route there; writes route to the primary. - Default.
DB::connection()- the primary, the oneDB::init()set up.
Caveats
- Active transactions IGNORE
on(name)(see §1 above). If you need a write on a different connection mid-tx, you can't - the tx is bound to one connection. - The reserved names
__primary__and__read_replica__cannot be used as user connection names.DB::register_namedreturns an error on collision. - Replica lag is YOUR problem. Suprnova does not retry-on-read or
fall back to primary when the replica is stale; if you need
read-your-writes after a save, use
Model::on_write_connection()explicitly.
Replication
Model::replicate() returns an unsaved copy of the model with the
primary key reset to its default. Useful for "duplicate this
record" UX where the user wants to start from an existing row.
let template: User = find_or_fail.await?;
let mut copy = template.replicate.await?; // id reset to default
copy.email = "fresh@example.com".into;
copy.save.await?; // INSERT, not UPDATE
replicate is async in Suprnova (diverges from Laravel) because
it fires the Replicating event - Saving / Created / etc.
listeners can mutate the replica before it's returned. See
Replicating event for the listener mutation
contract.
replicate_except
Drop named fields from the replica:
let copy = order.replicate_except.await?;
Listed fields fall back to the model's Default impl - Strings
become "", Options become None, etc. Use this for sensitive
columns the replicated row shouldn't carry forward.
Cross-type replicate_into::<T>
The Suprnova divergence - Laravel can't because PHP has no types.
replicate_into::<T>() bridges to a sibling type via
serde_json:
let order: Order = find_or_fail.await?;
let invoice: Invoice = order..await?;
invoice.save.await?;
Fields with matching names + serde-compatible types carry over;
fields that don't match on either side silently drop. T must
implement Default so unfilled fields have a value. Cross-type
replication does NOT fire Replicating (the event carries a
&mut Self - there's no way to address T through it). If you
need event-driven mutation, replicate same-type first and then
materialise T from the result.
Debugging - dump and dd
Two interactive debugging aids on every Builder<M>:
// Logs SQL + bindings via tracing::info!, returns self.
let users = query
.filter
.dump // → log line, builder continues
.order_by_desc
.get
.await?;
// Logs at tracing::error!, then panics with the SQL in the message.
query.filter.dd; // - !
dump is chainable; dd returns ! (never returns - the panic is
the contract). Both mirror Laravel's Builder::dump() /
Builder::dd() exactly.
Both helpers fall back to the SQLite dialect when no live DB
connection is bound (matches the to_sql_with_bindings fallback) so
they stay useful at REPL or in a test without TestDatabase.
The panic message uses the literal prefix eloquent dd: so tests
can assert against it:
Never commit dd() to a production code path. It's an
interactive debugging aid; the panic on the way out is the entire
point. dump() is safer (just logs) but spamming it in hot paths
will fill your logs - strip it before pushing.
If you want the SQL without the side effects, reach for the non-logging helpers:
Builder::to_sql()- returns the rendered SQL as aString.Builder::to_sql_with_bindings()- returns(String, Vec<SeaValue>).Builder::to_sql_for(backend)- render for an explicit dialect (cross-backend debugging).
Testing models
Tests instantiate a real database via TestDatabase, which
registers the connection in the per-test container so anything
calling DB::connection() inside the SUT resolves to the test DB.
Two entry points
TestDatabase::fresh::<MyMigrator>().await- runs every migration the production migrator runs. Use this for app-level dogfood tests where you want the test schema to exactly match whatsuprnova migrateproduces.TestDatabase::sqlite_memory().await- opens an in-memory SQLite database WITHOUT applying any migrations. Use this for framework-level unit tests where you want precise column-shape control via per-testdb.execute_unprepared("CREATE TABLE …").
App-level dogfood pattern
use Migrator;
use User;
use TestDatabase;
use ;
async
The _db binding holds the TestDatabase for the whole test -
dropping it tears the container down and releases the in-memory
SQLite connection. Don't shadow it to _ or the connection
disappears before the SUT runs.
Framework-level shape pattern
use TestDatabase;
use ;
async
Key patterns
TestDatabase::fresh::<MyMigrator>()for app-level tests with the production schema.TestDatabase::sqlite_memory()for unit-level shape tests.- Use
TestContainer::bind(NOTApp::bind) for any singletons the test mutates - global registry overrides race in parallel runs. TheTestDatabaseconstructor handles the DB binding for you. - Keep model declarations at module scope, not inside test fns.
The macro emits an inner
modwhoseuse super::*;only sees the file's top-level imports - declaring a model inside a test function breaks SeaORM type resolution.
Dropping to SeaORM
Three escape hatches keep SeaORM reachable from inside the Eloquent layer:
- The inner module -
user::Entity,user::Column,user::ActiveModel,user::Model. The macro emits these for every model; they're SeaORM types you can use directly. See Model module layout for the full layout and when to reach in. Fromconversions -From<user::Model> for UserandFrom<User> for user::Modelbridge between SeaORM-shape rows (storage-typed columns) and Eloquent-shape rows (runtime-typed columns). Useful when you want to issue a SeaORM query and convert the result to the Eloquent shape, or vice-versa.- The Suprnova-aliased SeaORM types - every SeaORM type a
consumer would touch is re-exported under
suprnova::*. You shouldn't needuse sea_orm::*in app code.
use ;
// Drop to SeaORM mid-query - Eloquent doesn't have a method for
// this, but SeaORM does:
let db = DBconnection?;
let users = find
.filter
.all
.await?;
// Convert to Eloquent shape:
let eloquent: = users.into_iter.map.collect;
Three escape hatches and the From bridge means the Eloquent layer never blocks you from reaching the underlying ORM.
Migrating from database::Model
Older code may carry impl suprnova::database::Model for Entity {}
on a hand-rolled SeaORM entity. The trait was renamed to EntityExt
to make room for the new Model trait - which sits on the
user-facing struct, not on the SeaORM entity.
The recommended migration path is to switch the type to
#[suprnova::model], which gives you the full Eloquent surface
plus the renamed EntityExt traits as a bonus. For the rare case
where you want to keep the old SeaORM-Entity-extension shape, the
EntityExt / EntityExtMut trait names are still available under
suprnova::database::*. They behave exactly like the old
database::Model did.
DB facade - model-less queries
Some tables don't belong on a #[suprnova::model] struct: short-lived
audit logs, ad-hoc reporting joins, dashboard aggregates. For those,
reach for the DB facade. Two surfaces sit under it:
DB::table(name) - chainable query builder
DbTableBuilder mirrors the where / order / limit shape of
Builder<M> but returns rows as DynamicRow (a typed-accessor
newtype over serde_json::Map<String, Value>):
use DB;
let rows = DBtable
.filter
.filter_op
.order_by_desc
.limit
.get
.await?;
for row in rows.iter
The full surface:
| Method | Returns | Purpose |
|---|---|---|
.select(["id", "event"]) |
DbTableBuilder |
Restrict columns (default *) |
.filter(col, val) |
DbTableBuilder |
WHERE col = ? |
.filter_op(col, op, val) |
DbTableBuilder |
WHERE col <op> ? |
.order_by_asc(col) / _desc(col) |
DbTableBuilder |
Ordering |
.limit(n) / .offset(n) |
DbTableBuilder |
Window |
.get() |
Collection<DynamicRow> |
All matching rows |
.first() |
Option<DynamicRow> |
First row or None |
.count() |
u64 |
SELECT COUNT(*) ... |
.insert(attrs) |
i64 |
New row's id |
.update(attrs) |
u64 |
Rows affected |
.delete() |
u64 |
Rows affected |
Identifier trust boundary. Table names, column names, SQL
operators, and ORDER BY directions are interpolated into the SQL
string verbatim - they are NOT bound as parameters. Pass only
trusted, compile-time literals to these arguments. Values (the
right-hand side of filter / filter_op) ARE bound and safe to pass
through from request data.
Empty WHERE on update / delete operates on every row.
DB::table("audit_log").delete().await? truncates the table by
design - add a filter if you don't mean that.
Insert backend split. RETURNING id is used on Postgres and
SQLite; MySQL runs the INSERT then issues
SELECT LAST_INSERT_ID() as id to recover the auto-increment.
DynamicRow - typed accessors over JSON map
DynamicRow wraps a serde_json::Map<String, Value> and exposes
typed getters. Each returns Result<T, FrameworkError> with a clear
error message on missing key or type mismatch:
let event: String = row.get_string?;
let actor_id: i64 = row.get_int?;
let active: bool = row.get_bool?;
let prefs: Prefs = row.get_as?; // any DeserializeOwned
let raw: Value = row.get_value?;
Nullable columns: use get_optional_*. These distinguish "column
missing" (error - schema mismatch) from "column present, value null"
(Ok(None)):
let score: = row.get_optional_int?;
let title: = row.get_optional_string?;
DynamicRow derefs to Map<String, Value>, so iteration and
key-existence checks work naturally:
for in row.iter
Raw-SQL escapes
When the builder isn't enough - window functions, recursive CTEs,
backend-specific DDL - drop to a raw string. Placeholders match the
active backend ($1, $2, ... for Postgres, ? for MySQL + SQLite):
// Raw SELECT, materialised as DynamicRow.
let rows = DBselect.await?;
// Raw UPDATE / DELETE - return rows-affected.
let updated = DBupdate.await?;
let deleted = DBdelete.await?;
// Raw DDL or no-binding statements.
DBstatement
.await?;
// Generic affecting statement - for INSERT ... ON CONFLICT etc.
let rows = DBaffecting_statement.await?;
Use these escape hatches sparingly - the typed builder catches more errors at compile time and reads cleaner in business logic. But when you need them, they're here.
A computed column such as SELECT COUNT(*) AS n FROM t comes back in
raw DB::select rows on every backend. SQLite declares no type for it,
so Suprnova reads the value by its runtime type; see
Computed columns on SQLite.
Relation-existence + cheap shortcuts
Suprnova mirrors Laravel's relation-existence query family. Every method here pairs the Laravel-shape name with an idiomatic Rust alias (Suprnova's standing dual-API convention).
Relation-existence filters (has / where_has / where_belongs_to)
The correlated EXISTS (...) family constrains the parent query by
the existence (or absence, or count) of related rows, without joining
the relation into the outer SELECT.
use Model;
// Users who have at least one post.
let users = query.has.get.await?;
// Users who have NO posts.
let empty = query.doesnt_have.get.await?;
// Users with >= 3 posts (Laravel `has("posts", ">=", 3)`).
let prolific = query.has_count.get.await?;
// Inner constraint via closure - restrict the EXISTS subquery body.
let recent = query
.
.get
.await?;
// One-column shortcut - equivalent to `where_has` with a tiny closure.
let with_pub = query
.where_relation
.get
.await?;
// Belongs-to direct join (no EXISTS - FK lives on this table).
let posts = query.where_belongs_to.get.await?;
All variants compose with or_* and *_doesnt_have companions:
has/or_has/has_count/doesnt_have/or_doesnt_havewhere_has/or_where_has/where_doesnt_have/or_where_doesnt_havewhere_relation/where_relation_op/or_where_relationwhere_belongs_to
The engine reads relation metadata from the macro-generated
RelationEntry inventory: join columns, pivot tables, morph
discriminators all flow through automatically. Three subquery shapes
are rendered:
- Has -
EXISTS (SELECT 1 FROM child WHERE child.fk = parent.pk) - Pivot -
EXISTS (SELECT 1 FROM pivot INNER JOIN target ON ... WHERE pivot.parent_fk = parent.pk) - Morph - has/pivot shape plus
AND target.<morph>_type = '<value>'
Unknown relation names render the safe-fail form (EXISTS (SELECT 1 WHERE 1 = 0)), which evaluates to FALSE and returns zero rows. A
typo never leaks a full-table scan.
MorphTo existence
You use has_morph or doesnt_have_morph with your MorphTo relation's
name and registered morph names. You also use a model's registered alias
or Rust type name. You pass "*" to query every owner type the relation
declares in its targets list, an owner without a morph_type included.
The wildcard absence form includes rows whose type column is null.
Explicit type lists match only those types, including their aliases.
let comments = query
.where_has_morph
.get
.await?;
let missing = query
.doesnt_have_morph
.get
.await?;
You use where_has_morph or where_doesnt_have_morph to constrain each
type's existence query. Your closure receives a builder and the canonical
morph name once per selected type. The target's global scopes and soft-delete
filter apply. An unknown morph type or a relation that is not MorphTo
returns an error when you compile or execute the query. An empty type list
matches no rows for existence, and every row for absence.
Cheap builder shortcuts
// PK filters.
query.where_key.first.await?; // sugar for filter("id", 7)
query.where_key_not.get.await?; // sugar for filter_op("id", "!=", 7)
query.filter.or_where_key.get.await?; // ... OR id = 7
query.filter.or_where_key_not.get.await?; // ... OR id != 7
// Rust-idiomatic aliases: filter_key / filter_key_not /
// or_filter_key / or_filter_key_not.
// Order by created_at.
query.latest.get.await?; // ORDER BY created_at DESC
query.oldest.get.await?; // ORDER BY created_at ASC
query.latest_by.get.await?; // named column
// Exact-one matching.
let one = query.filter.sole.await?; // errors on 0 or >1
let val: i64 = query.filter.sole_value.await?;
let v: i64 = query.filter.value_or_fail.await?;
// Eager-load opt-outs.
query.with.without.get.await?;
query.with_only.get.await?; // wipes the plan first
// Fully-qualified columns (for joins).
qualify_column; // -> "users.name"
qualify_columns; // -> ["users.name", "users.id"]
Mass mutation - update_all / delete_all / upsert / *_each
These hit the database directly with a single statement and do NOT
fire per-row model events. Use them when scope-narrowing is sufficient
and you don't need lifecycle hooks; for per-row hooks iterate with
.get() and call .update() / .delete() per row.
delete_all always targets the model's static M::TABLE; runtime table
names are not accepted as executable SQL. On a soft_deletes model it
soft-deletes: one UPDATE sets the deleted-at column across the scope,
as Laravel's builder delete() does. force_delete_all removes the rows
for good.
Explicit null attributes are emitted as SQL NULL, so nullable bigint,
integer, boolean, timestamp, and other non-text columns retain their database
type on PostgreSQL. Every non-null attribute remains parameter-bound. Upsert
rows must have the same column set; a missing or extra key is rejected rather
than being interpreted as null.
// Mass UPDATE.
let n = query
.filter
.update_all
.await?;
// Mass DELETE.
let n = query
.filter_op
.delete_all
.await?;
// INSERT ... ON CONFLICT (Postgres / SQLite) / ON DUPLICATE KEY UPDATE (MySQL).
let n = query
.upsert
.await?;
// Atomic increment/decrement against a scope.
query
.filter
.increment_each
.await?;
query
.filter
.decrement_each
.await?;
Static Model helpers
// Mass-destroy by PK set. Per-row events fire (each row goes through
// .delete() so soft-delete tombstone semantics + Deleting/Deleted
// dispatch are honoured).
let removed: u64 = destroy.await?;
let removed: u64 = force_destroy.await?;
// Identity comparison by PK.
assert!;
assert!;
You use force_destroy(keys) to remove matching rows physically, including
rows you already soft-deleted. You receive the number removed. Duplicate
or missing keys add nothing to the count, and an empty list returns zero.
Each removed row fires its force-delete lifecycle events.
*Quietly variants - suppress lifecycle events
Sugar over seed::without_events. The five static lifecycle events
(Saving/Creating/Updating/Deleting/Restoring) and the
non-cancellable after-events both short-circuit inside the scope.
user.save_quietly.await?; // no Saving / Updated / Saved
user.update_quietly.await?;
user.delete_quietly.await?;
user.force_delete_quietly.await?;
*_or_fail variants
Explicit error on the not-found case. Useful in invariant-checking code paths where a missing row is a bug.
let user = user.update_or_fail.await?; // not_found if row deleted mid-flight
user.delete_or_fail.await?;
On a model declared with soft_deletes, delete_or_fail, delete_quietly,
destroy, and a delete called through the Model trait all tombstone
the row, as delete() does. delete_or_fail answers not-found for a row
that is already trashed.
Filtered serialisation - to_array_except / to_array_only
Suprnova's Rust-native replacement for Laravel's per-instance
makeHidden / makeVisible. The Eloquent struct doesn't carry a
runtime attribute bag, so the column list is supplied at the call
site:
return ok;
return ok;
Divergence note. Laravel's per-instance makeHidden mutates state
that propagates when the model is nested inside a parent's toArray()
call. Suprnova's filter is terminal - it produces a serde_json::Value
and doesn't affect future serialisations of self. For
declarative-and-permanent visibility control, use the #[model(hidden = [...])] / #[model(visible = [...])] attributes.
UUID / ULID primary keys - #[model(unique_id = "...")]
Suprnova's analogue of Laravel's HasUuids / HasUlids /
HasVersion4Uuids trait family. Set the attribute, type the PK as
String, and the macro auto-populates the ID before INSERT. The key type
comes from the String field.
// Auto-populated:
let u = create.await?;
// u.id is a fresh UUID v7.
// Caller-supplied IDs still win (matches Laravel's HasUuids behaviour).
let u = create.await?;
Supported strategies:
"uuid"/"uuid_v7"- UUID v7 (timestamp-ordered, recommended; matches Laravel 11+'s defaultStr::uuid7())"uuid_v4"- random UUID (matchesHasVersion4Uuids)"ulid"- lowercase 26-char Crockford-base32 ULID
The macro emits an impl HasUniqueId for YourStruct block exposing
UNIQUE_ID_KIND and a new_unique_id() hook you can override on the
type for a custom generator (e.g. prefixed IDs like usr_<uuid>).
find_or / find_or_new / create_or_first
Round out the FirstOrCreate trait surface.
// Look up by PK; run fallback if not found.
let user = find_or.await?;
// Look up by PK; build an unsaved instance from defaults if not found.
let user = find_or_new.await?;
// user.id == 0 here - the instance is in-memory only; insert it with
// `user.persist().await?`.
// Race-safe insert: try create, fall back to fetch on conflict.
let user = create_or_first.await?;
without_touching scope
The Suprnova analogue of Laravel's Model::withoutTouching. Inside
the scope, every model.touch().await call short-circuits - useful
when running data migrations or batch jobs that mutate timestamps
through other paths.
use without_touching;
without_touching.await;
The scope is tokio::task_local-backed, so concurrent requests on
other tasks continue to honour their own scope (or its absence).
without_touching also suppresses the parent-touch
cascade - a child saved inside the scope leaves
every owner named in its touches list alone.
without_touching_on::<Post, _, _>(fut) is the per-type form -
Laravel's Model::withoutTouchingOn([Post::class], $cb). Inside it,
post.touch() and any cascade that would bump a Post go quiet, while
owners of every other type keep bumping:
use without_touching_on;
.await?;
The per-type form also holds for a MorphTo owner: when the owner the
row points at is a Post, the touch is skipped.
Scopes nest, and both are tokio::task_local-backed.
Next
- Eloquent Relationships - deep dive on every relation kind, the morph registry, and the polymorphic enum lowering
- Eloquent Collections - full
Collection<T>surface, the generic-vs-model split, andLazyCollection<M>streaming - Eloquent Casts, Accessors & Mutators - the
31 built-in casts plus the
casts!runtime override - Eloquent Serialization -
to_array,to_json, hidden / visible / appends, filtered terminals - Eloquent Factories - randomized model instances for tests and seeders
