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.
Raw original attributes
You read the database snapshot before casts with get_raw_originals():
let raw = user.get_raw_originals?;
let original_role = user.get_raw_original;
let original_note = user.get_raw_original_or;
You get every loaded column in an Attrs object, including hidden columns.
You get integer boolean storage and JSON text before the casts decode them.
Changes to the current fields do not change this snapshot. A successful save
refreshes it. A model you have not loaded or saved has an empty snapshot.
You use the default only for an absent attribute; a loaded null stays null.
You keep get_raw_original(attribute) for a single optional stored value.
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 ↔ an integer column holding 0 / 1, read and written as i64.
SQLite has no native boolean column, so this is the shape a boolean
takes there. On Postgres the column has to be BIGINT: Postgres pins
INTEGER to 32 bits, which the driver will not read as an i64. On
MySQL and MariaDB any integer column works.
A native boolean column - what the schema builder's boolean() creates
on Postgres - takes no cast: declare the field bool. Postgres refuses
AsBool's integer for a BOOLEAN column.
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.
Writes are normalized to RFC-3339. Reads also accept the native
CURRENT_TIMESTAMP text emitted by PostgreSQL and timezone-free SQLite/MySQL
values; timezone-free values are interpreted as UTC. AsImmutableDateTime and
AsOptionalDateTime use the same parser.
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:
Native date-time casts
The casts above store text. These four store a DateTime<Utc> in a native
date-time column, so the database's own date functions - grouping by
month, date arithmetic, NOW() comparisons - work on it:
| Cast | Field | Column |
|---|---|---|
AsNativeDateTime |
DateTime<Utc> |
timestamp with time zone on Postgres, TIMESTAMP or DATETIME on MySQL, text on SQLite |
AsOptionalNativeDateTime |
Option<DateTime<Utc>> |
the same, nullable |
AsNaiveDateTime |
DateTime<Utc> |
a column without a zone holding the UTC wall clock: timestamp on Postgres, DATETIME on MySQL, text on SQLite |
AsOptionalNaiveDateTime |
Option<DateTime<Utc>> |
the same, nullable |
A DateTime<Utc> field defaults to AsDateTime, so declare these per
field, or for a whole package with datetime_cast (described below). The
model's automatic timestamps, touch(), soft deletes and the touch of an
owner all store through the declared cast.
Pick the cast that matches the column: the database driver checks the
storage type against it. A table Laravel's timestamps() created is
TIMESTAMP on MySQL, which only AsNativeDateTime reads, and timestamp without time zone on Postgres, which only AsNaiveDateTime reads. The
schema builder's timestamps_tz() and datetimes() create the two shapes
(see Migrations).
An application whose tables all have native date-time columns, such as one
ported from Laravel, can choose the cast once for a whole package in its
Cargo.toml, instead of naming it on each field:
[package.metadata.suprnova.model]
datetime_cast = "native"
#[suprnova::model] reads the setting from the package that declares the
model. Every DateTime<Utc> field without a cast of its own then gets
AsNativeDateTime, and every Option<DateTime<Utc>> field gets
AsOptionalNativeDateTime, the managed created_at, updated_at and
deleted_at included. The setting converts no column, so each value needs
its own columns:
| Value | Casts | Columns it needs |
|---|---|---|
"native" |
AsNativeDateTime, AsOptionalNativeDateTime |
TIMESTAMP (what Laravel's timestamps() creates on MySQL) or DATETIME on MySQL, timestamp with time zone on Postgres, text on SQLite |
"naive" |
AsNaiveDateTime, AsOptionalNaiveDateTime |
DATETIME on MySQL, timestamp (what Laravel's timestamps() creates on Postgres) on Postgres, text on SQLite |
Without the setting, the cast is AsDateTime, which needs a text column. A
field whose column differs from the rest names its own cast, which wins:
A key or a value the framework doesn't know fails the build of the model and
names it. A new project carries the setting commented out in Cargo.toml.
Laravel's timestamp columns are nullable, and another application can leave
them NULL. Declare such fields Option<DateTime<Utc>> with
AsOptionalNativeDateTime or AsOptionalNaiveDateTime: a NULL reads as
None, and the model's own writes still stamp them.
Queries bind through the cast too, so filter_op("created_at", ">", "2031-03-14T12:00:00Z"),
where_between, where_date and update_all send a native parameter for a
native column; Postgres refuses to compare one with text. A bare date such
as "2031-03-14" compares as midnight UTC. Two queries still bind text: a
model-less DB::table query, which knows no casts, and a where_has
through a MorphTo relation, whose related model varies by row. On
Postgres, compare a native column there in raw SQL with a typed value.
Why Suprnova diverges
Laravel's datetime cast writes whatever column the migration made, because
PHP binds every value as text and each database converts it. Postgres
refuses a text parameter for a date-time column, so a Suprnova cast names
its storage type and the default stays the text that round-trips on every
backend.
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. They need a text column: Postgres refuses their text for a
JSON or JSONB column, and the MySQL driver will not read MySQL's
JSON type as text. For a native JSON column, use
AsNativeJson<T>.
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).
Nullable JSON columns
The structured casts store non-null text, so they can't serve a
nullable column: AsJson<Option<T>> writes the text null instead
of SQL NULL, and a NULL row fails to decode. Each one has an
optional form for that case - AsOptionalArray<T>,
AsOptionalObject<T>, AsOptionalCollection<T>, AsOptionalJson<T>
and AsOptionalArrayObject<T>. The field is an Option of the plain
cast's type. None stores SQL NULL and a NULL column reads as
None. A value is stored and read exactly as the plain cast does it,
with the same error on malformed JSON.
use AsOptionalJson;
AsNativeJson<T>
Any Serialize + DeserializeOwned type ↔ a native JSON column:
JSONB or JSON on Postgres, JSON on MySQL and MariaDB, TEXT on
SQLite. It stores the value as JSON rather than text, so the column can
be native and the database's JSON operators and indexes work on it. A
struct, a Vec<T>, an IndexMap<String, T> or a serde_json::Value
all fit as T, so it covers what AsObject, AsArray,
AsArrayObject and AsJson do over text. AsOptionalNativeJson<T>
is the nullable form: None stores SQL NULL. An update_all or a
filter on the column binds a JSON parameter too.
use ;
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.
AsEnumCollection<E>
You store Vec<E> in a JSON column: json or jsonb on Postgres, json
on MySQL and MariaDB, or text holding JSON on SQLite. The cast stores a JSON
array, so the column must be native JSON on Postgres, which refuses to read a
json or jsonb value into text. Each element uses the same AsRef<str>
value as AsEnum<E>, rather than its serde enum name:
use ;
You read a Laravel enum collection such as ["Admin","Editor"] into your
variants and write only those storage strings, as a JSON array. You preserve
order and repeated variants. An empty vector stores []. A value that is not a
JSON array of strings, and unknown variants, return a validation error. You use
the same FromStr + AsRef<str> bounds as AsEnum; runtime cast overrides also
require Serialize + DeserializeOwned so the dispatcher can represent your
variants.
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.
Runtime appends
You register an accessor for runtime selection with accessors = [...].
You add it to one model instance with append(name):
user.append?;
let body = user.to_array;
You keep the name on that instance for every later array, JSON, and serde
conversion. Repeated appends add the name once. An unregistered name returns
an error without changing the output. You register default appends through
appends = [...]; those names also support append.
You filter default and runtime appends through the model's hidden and visible
lists before calling the accessor. You reveal or hide an appended name with
make_visible or make_hidden just as you do a stored attribute.
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:
You strip the named source columns with hidden. You include the accessor
when its own name passes the same hidden and visible filters. 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!;
let copy = copy.persist.await?; // inserted with a new PK (`use suprnova::Persistable;`)
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 persist() like any new row. save() refuses a replica:
it has no row yet, and its reset key would name another 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.
