A cast mediates the boundary between what a column holds on disk and what your model carries in memory. An accessor invents a virtual attribute from the columns you already have. A mutator routes writes to a field through your own transform. Together with auto-managed timestamps, they are the four moving parts that turn a flat row into a typed Rust value.
This chapter covers the full cast surface (every built-in type, the
casts! runtime override, encryption and hashing), the
#[accessor] and #[mutator] attribute macros, the
auto-timestamp contract including touch() and without_touching,
and the Replicating lifecycle event that fires when you clone a
model with replicate().
For the broader model surface (#[suprnova::model], query builder,
relationships, observers) see the Eloquent API chapter.
For lifecycle events end-to-end see Events & Listeners.
For the crypto facade the encrypted casts use see
Encryption.
How casts work
Every cast is a struct that implements the Cast trait:
Runtime is the Rust type you write in your model struct
(bool, chrono::NaiveDate, rust_decimal::Decimal, your own
enum). Storage is the type SeaORM sees on the column
(i64 for an SQLite boolean column, String for a TEXT date).
Both directions are fallible - temporal and decimal parsing can
reject malformed input - so the macro propagates the Result
through From<inner::Model> and the ActiveModel write path.
Casts are explicit. A Vec<String> field does not implicitly become
AsArray<String> because field-type inspection at macro time would
break the moment you renamed an alias or imported a different Vec.
You declare casts on the macro attribute:
use ;
The macro expands each field = CastType entry into calls into the
Cast::to_storage and Cast::from_storage on every read and write.
You never invoke the cast yourself - you write the runtime type,
the cast wires the column shape.
Why Suprnova diverges
Laravel declares casts as protected $casts = ['tags' => 'array'].
The string 'array' resolves to a class via a runtime lookup, which
means cast names live as untyped strings until they run. Suprnova
takes the type directly - AsArray<String> is a real Rust type
that the macro checks at compile time. A typo in the cast name is a
compile error, not a runtime exception three weeks after deploy.
The primitive casts
Five casts cover the SQL scalar types.
AsBool
bool ↔ INTEGER (0 / 1). SQLite has no native boolean column;
Postgres and MySQL both round-trip i64 cleanly through SeaORM's
Value::Int boundary. A single storage shape lets you use the same
cast against every backend.
AsInt<I>
A narrower integer (i32, u32, i16) ↔ i64. SeaORM stores
integers as i64 on the column; the cast narrows on read and
widens on write. Out-of-range values produce a validation error at
read time rather than silently truncating.
Use AsInt<i64> (or omit the cast) when the runtime type already
matches storage.
AsFloat
f64 ↔ REAL. Pass-through both directions - the cast exists for
naming parity with Laravel's 'float' cast; backends round-trip
floats natively.
AsString
String ↔ TEXT. Also pass-through; the cast exists so the
Builder::with_casts(...) runtime override can erase it to a
DynCast like every other cast.
AsDecimal<P>
rust_decimal::Decimal ↔ TEXT. P is the precision (number of
decimal places); values are rounded to P places on the way to
storage. Default is P = 4. Storage is a fixed-format string so
round-trips are backend-agnostic - SeaORM's native Decimal column
type has different precision semantics on each driver, and the
string round-trip avoids that.
use Decimal;
use AsDecimal;
The temporal casts
Six casts cover dates, datetimes, immutable variants, and Unix
timestamps. All non-timestamp casts store as TEXT (ISO-8601 /
RFC-3339) so the round-trip works on every driver - SQLite stores
datetimes as strings natively, and Postgres / MySQL accept them
through SeaORM's Value::String boundary.
AsDate
chrono::NaiveDate ↔ TEXT (YYYY-MM-DD).
use NaiveDate;
use AsDate;
AsDateTime
chrono::DateTime<Utc> ↔ TEXT (RFC-3339). The default cast for
arbitrary timestamps when you want a wall-clock representation.
AsImmutableDate and AsImmutableDateTime
Same storage shape as AsDate / AsDateTime. Rust's borrow checker
already enforces immutability through & references, so these casts
share the underlying types - they exist for parity with Laravel's
immutable_date / immutable_datetime and to document intent at
the model declaration site.
AsOptionalDateTime
Option<DateTime<Utc>> ↔ Option<String>. Auto-injected by the
#[model(soft_deletes)] flag for the nullable tombstone column
(deleted_at by default - see Soft deletes).
The wrapped option keeps the storage column nullable so soft-deleted
vs alive rows discriminate on IS NULL without a sentinel value.
Use the cast directly on any other nullable datetime column you want to round-trip as RFC-3339 text:
AsTimestamp
Unix-epoch i64 ↔ INTEGER. Use when the column is queried as a
numeric range or used in arithmetic. Distinct from AsDateTime -
pick AsTimestamp when you want WHERE created_unix > 1700000000
and AsDateTime when you want RFC-3339 strings in your logs.
The structured casts
Five casts cover collections, structs, and arbitrary JSON. All
serialise the runtime value to JSON text and store it in a TEXT
column. Postgres native JSON / JSONB and MySQL JSON columns
accept the same string payload - if you want a native JSON column
type for indexing, declare it manually in a migration; the cast
layer doesn't constrain the column type.
AsArray<T>
Vec<T> ↔ JSON-encoded TEXT. Element type must be
Serialize + DeserializeOwned.
use AsArray;
AsObject<T>
A Serialize + DeserializeOwned struct ↔ JSON-encoded TEXT. Use
when the runtime shape is a fixed record with statically-known keys.
use ;
use AsObject;
AsCollection<T>
Collection<T> ↔ JSON-encoded TEXT. Thin wrapper over AsArray
that round-trips through Suprnova's Collection<T> (a Vec<T>
newtype with the Laravel-style slice surface - see
Collections).
AsJson<T>
Any Serialize + DeserializeOwned type ↔ JSON-encoded TEXT. Use
when the field is a serde_json::Value or a user-defined struct
that's already fully describable in serde terms but doesn't fit the
fixed-shape AsObject pattern (e.g. enum payloads, untyped maps).
AsArrayObject<T>
IndexMap<String, T> ↔ JSON-encoded TEXT. Use when the runtime
shape is a dynamic-key map and the order of keys matters (the UI
ordering of labels, the canonical order of a config block). IndexMap
over HashMap is intentional: serde preserves insertion order
through IndexMap, and Suprnova's serde_json is already configured
with preserve_order for the same reason.
For fixed-shape records use AsObject; for arrays use AsArray.
The enum cast
AsEnum<E>
E: FromStr + AsRef<str> ↔ TEXT. The enum's variant name (or its
AsRefStr-customised string) is what hits the column. There is no
framework lock-in on strum, but it's the most ergonomic way to get
the two bounds without hand-rolling them:
use AsEnum;
Integer-discriminant storage is intentionally not the default. A
Role::Admin = 0 that later becomes Role::Admin = 2 after a
re-order would silently swap every admin in the database. Variant
names are self-describing in a DB browser and stable across
re-orders.
Encryption and hashing
Five casts mediate cryptographic transforms on the storage boundary.
All four AsEncrypted* casts share the Crypt
facade - the facade must be initialised before any of them run.
Production apps get this through Server::from_config (which reads
APP_KEY from the environment); tests call
suprnova::testing::install_test_encryption_key() once at startup.
AsEncrypted
String ↔ AES-256-GCM-encrypted String. The on-disk column holds
URL-safe base64 of nonce || ciphertext_with_tag. Each write uses a
fresh random nonce, so two writes of the same plaintext produce
distinct ciphertexts - your DB admin cannot identify duplicate
secrets at rest.
use AsEncrypted;
The runtime value is the decrypted UTF-8 string; you read and write
it like any other String.
AsEncryptedArray<T> / AsEncryptedObject<T> / AsEncryptedCollection<T>
Vec<T> / T / Collection<T> ↔ AES-256-GCM-encrypted JSON. Pipeline
is: serialise to JSON → encrypt → base64 → store; reverse on read.
Element / value type must be Serialize + DeserializeOwned.
use AsEncryptedObject;
use ;
Key rotation
The Crypt facade supports rotation through APP_KEY_PREVIOUS:
encryption always uses APP_KEY, but decryption tries APP_KEY
first and falls back to APP_KEY_PREVIOUS if the primary key fails.
A rolling re-encryption strategy is: set APP_KEY to the new key,
move the old key to APP_KEY_PREVIOUS, then save() every encrypted
row to rewrite ciphertexts under the new key. The cast layer does
not have to know about rotation - it round-trips through Crypt on
every read and write, so a User::all().await? followed by saving
each row migrates the column in place. See Encryption
for the full rotation protocol.
AsHashed
String ↔ a hashed string on write, using the active hash driver
(HASH_DRIVER env var - bcrypt by default, argon2i and argon2id
also supported). The runtime value IS the hashed string; there is
no reverse direction. Mirrors Laravel's hashed cast.
use AsHashed;
AsHashed::to_storage is idempotent: a value that already looks
like ANY recognised hash (bcrypt $2*$, argon2i / argon2id PHC)
passes through unchanged. Without this guard,
User::find(id).await?.save().await? would re-hash the existing
hash into a hash-of-hash, breaking Hash::check(plain, stored) and
invalidating every existing password.
Pair AsHashed with the #[mutator] pattern (below) when you need
to apply more than a hash on write - e.g. normalise whitespace or
reject blank passwords before hashing.
Runtime cast override - casts! macro
The casts declared in #[model(casts = { ... })] are static - they
fire on every read of that model. When you need a different cast on a
single query (a debug tool wants the raw stored shape, an export
script wants a different JSON representation), use
Builder::with_casts(...):
use ;
let map = casts! ;
let rows = query.with_casts.get.await?;
The casts! macro builds a HashMap<&'static str, Arc<dyn DynCast>>.
Each entry is field_name = CastType; every built-in cast implements
IntoDynCast, so the type-erased DynCast shadow is automatic. The
runtime-override map only applies for the duration of the chained
query - the model's static cast pipeline is unchanged.
Use this surface sparingly. The model attribute is the right place for the casts you want every read to apply; the runtime override is the escape hatch for one-off queries.
Accessors - virtual attributes from real columns
An accessor is an impl method on the model annotated with the
#[accessor] macro. When you list the method's name in
#[model(appends = [...])], the model's to_json() calls the
method and inserts the result under that key.
use ;
A serde_json::to_value(&user) (or user.to_json()) now contains:
The method is also callable directly (user.full_name()) - the
#[accessor] macro is mostly a marker so the struct-level
#[suprnova::model] macro can wire the to_json() dispatch. There
is no cost to calling it from your own code.
Each name in appends must match a real #[accessor] method by
identifier. A typo (appends = ["fullName"] when the method is
full_name) is caught at compile time with a pointed error message.
Returning non-String values
Accessors can return any Serialize type. The macro converts the
returned value through serde_json::to_value before insertion, so:
renders as "word_count": 42 in the JSON output.
Hiding the source columns
When the accessor's value is what the consumer should see and the
underlying columns are noise, pair appends with hidden:
hidden strips the named columns from the serialised output;
appends then inserts the accessor's value. The order is fixed -
filters run first, accessor injection runs after. See
Hidden, visible, and appends for the
complete surface.
Mutators - routed writes through your transform
A mutator is the write-side counterpart. When the field's name appears
in #[model(mutators = [...])], every mass-assignment path (create /
update) routes the value through self.set_<field>(value)? instead
of assigning the field directly.
use Value;
use ;
set_password receives a serde_json::Value. The body owns the
deserialise + transform - the field type on the struct can stay
String, and your validation runs before the column is touched.
A returned error propagates through create() / update() as a
bad_request.
Direct field assignment bypasses the mutator:
user.password = "raw".to_string; // skips set_password
user.save.await?; // saves "raw"
This matches Laravel's $user->password = ... vs $user->fill(...)
behaviour. When you want the mutator to be the only path, route
all writes through attrs! + create / update.
Combining mutators with casts
A mutator and a cast can coexist on the same field; the mutator runs
on the write path (when create / update is called), the cast runs
on the read path (when the column is materialised from a SELECT).
A common pattern is to use AsHashed for the read-side idempotence
guarantee and the mutator for write-side validation - the mutator
hashes, AsHashed sees an already-hashed value and passes through.
Auto-managed timestamps
When a model carries both created_at and updated_at fields
(typed chrono::DateTime<chrono::Utc>), the macro:
- Sets both to
Utc::now()oncreate(). - Bumps
updated_aton everysave()andupdate(attrs). - Emits an
impl Touchable for YourStructso you can call.touch().awaitto bumpupdated_atwithout changing any other column.
use ;
use ;
// Bump updated_at without other changes:
let post = find_or_fail.await?;
post.touch.await?;
Storage uses the AsDateTime cast that the macro auto-injects for
timestamp columns. The cast lets the same DateTime<Utc> value
round-trip across all three SeaORM drivers (SQLite, MySQL,
PostgreSQL) without forcing you to pick a database-specific
timestamp type.
Opt-out and custom column names
#[model(timestamps = false)] disables the auto-management entirely -
you control the timestamps yourself.
#[model(created_at = "creado_en", updated_at = "actualizado_en")]
keeps the auto-management but renames the columns. The macro
detects the renamed fields and wires the same logic against them.
When the struct has only ONE of the two timestamp fields, the macro
emits a compile_error! - almost always a typo (craeted_at)
that you want surfaced loudly rather than silently swallowed.
without_touching - task-scoped suppression
Sometimes you want to update a row without bumping updated_at -
running a backfill, fixing a typo, recording an internal sync that
shouldn't reset cache TTLs keyed on updated_at. Wrap the work in
without_touching:
use without_touching;
without_touching.await?;
The flag is a tokio::task_local! so it doesn't leak across
tokio::spawn boundaries - concurrent requests on other tasks
continue to honour their own scope (or its absence). This is the
Suprnova analogue of Laravel's Model::withoutTouching(closure).
Why Suprnova diverges
Laravel uses a static $timestamps = false property and a global
Model::withoutTouching static method backed by an instance counter.
Both approaches assume request-per-process isolation. Suprnova runs
many requests on one Tokio runtime, so a process-global flag would
let one request silently suppress timestamps on another. The
tokio::task_local! scope is async-aware: it follows futures
across .await points within the same task and goes out of scope
when the future drops, no matter how the request ends.
The Replicating lifecycle event
Of the 16 model lifecycle events (see Observers and lifecycle
events), Replicating
is the one that fires when you clone an existing row into an unsaved
in-memory copy via replicate():
let original = find_or_fail.await?;
let mut copy = original.replicate.await?; // unsaved
copy.title = format!;
copy.save.await?; // now persisted with a new PK
The Replicating event fires AFTER the in-memory clone is built but
BEFORE you've had a chance to mutate it. Listeners receive
(&Self, Arc<Mutex<Self>>) - the original and the freshly-built
replica behind a Mutex, so you can mutate the replica from the
listener before the user sees it:
use ;
;
The replica's PK is already cleared by the time the listener runs -
replicate() calls reset_primary_key() before firing the
event, so you can't accidentally re-save under the original ID.
Timestamps are also reset; created_at / updated_at fire on the
subsequent save() like any new row.
replicate_into<T> - cross-type replication
When the replica is a different type (Post → Draft, say), use
replicate_into::<Draft>(). The Replicating event does NOT fire on
this path because the event struct is per-source-type and a listener
registered for post::events::Replicating would receive an
Arc<Mutex<Post>>, not an Arc<Mutex<Draft>>. The cross-type path
is for when you want a fresh target type without observer interference;
register a normal Creating listener on the target type if you want a
hook at construction.
See Replication for the rest of the
replicate surface (replicate_except, the replica's relation
handling, the rules for nullable PKs).
Putting it together
A model with every surface from this chapter:
use ;
use Decimal;
use ;
use Value;
use ;
This single declaration gives you:
- Eight typed casts wiring the storage / runtime boundary.
- An accessor that synthesises
display_namefrom existing columns. - A mutator that validates and hashes the password.
- Auto-managed
created_at/updated_at. - Soft deletes with an auto-injected
deleted_atcolumn. - Encrypted card-on-file storage with key-rotation support.
Every cast is checked at compile time. The dual-API query builder
(see Eloquent - query builder)
runs against the typed columns; serialisation to Inertia / JSON
applies the hidden / appends rules; and a User::find(id).await?
materialises the row through eight Cast::from_storage calls
without you writing a single line of conversion code.
Next
- Eloquent API - the rest of the model surface: query builder, relationships, observers, pagination, transactions.
- Encryption - the
Cryptfacade the encrypted casts share, key rotation protocol, and the wider crypto surface. - Events & Listeners - the dispatcher behind
Replicatingand the other 15 model lifecycle events. - Authentication - the
Authenticatabletrait and whereAsHashedfits into the password flow. - Validation -
FrameworkError::validationand the pattern mutators use to surface per-field errors.
