Manual contentsEloquent ORMBrowse 103 chapters
Manual 21 min read

Eloquent Casts, Accessors & Mutators

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:

pub trait Cast: Send + Sync {
    type Runtime;
    type Storage;

    fn to_storage(value: &Self::Runtime) -> Result<Self::Storage, FrameworkError>;
    fn from_storage(stored: &Self::Storage) -> Result<Self::Runtime, FrameworkError>;
}

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 suprnova::{model, AsArray, AsBool, AsJson};

#[model(
    table = "posts",
    casts = {
        tags = AsArray<String>,
        published = AsBool,
        metadata = AsJson<serde_json::Value>,
    },
)]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub tags: Vec<String>,
    pub published: bool,
    pub metadata: serde_json::Value,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

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

boolINTEGER (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.

#[model(table = "settings", casts = { dark_mode = AsBool })]
pub struct Settings {
    pub id: i64,
    pub dark_mode: bool,
}

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.

#[model(table = "counters", casts = { age = AsInt<u32> })]
pub struct Counter {
    pub id: i64,
    pub age: u32,
}

Use AsInt<i64> (or omit the cast) when the runtime type already matches storage.

AsFloat

f64REAL. Pass-through both directions - the cast exists for naming parity with Laravel's 'float' cast; backends round-trip floats natively.

AsString

StringTEXT. 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::DecimalTEXT. 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 rust_decimal::Decimal;
use suprnova::AsDecimal;

#[model(
    table = "ledger",
    casts = { amount = AsDecimal<2> },  // currency, 2 dp
)]
pub struct LedgerEntry {
    pub id: i64,
    pub amount: Decimal,
}

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::NaiveDateTEXT (YYYY-MM-DD).

use chrono::NaiveDate;
use suprnova::AsDate;

#[model(table = "people", casts = { birthday = AsDate })]
pub struct Person {
    pub id: i64,
    pub birthday: NaiveDate,
}

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:

#[model(
    table = "subscriptions",
    casts = { cancelled_at = AsOptionalDateTime },
)]
pub struct Subscription {
    pub id: i64,
    pub cancelled_at: Option<chrono::DateTime<chrono::Utc>>,
}

AsTimestamp

Unix-epoch i64INTEGER. 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 suprnova::AsArray;

#[model(table = "posts", casts = { tags = AsArray<String> })]
pub struct Post {
    pub id: i64,
    pub tags: Vec<String>,
}

AsObject<T>

A Serialize + DeserializeOwned struct ↔ JSON-encoded TEXT. Use when the runtime shape is a fixed record with statically-known keys.

use serde::{Deserialize, Serialize};
use suprnova::AsObject;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Prefs {
    pub theme: String,
    pub notifications: bool,
}

#[model(table = "users", casts = { prefs = AsObject<Prefs> })]
pub struct User {
    pub id: i64,
    pub prefs: Prefs,
}

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

#[derive(Debug, Clone, Copy, strum::EnumString, strum::AsRefStr)]
pub enum Role {
    Admin,
    Editor,
    Viewer,
}

#[model(
    table = "users",
    casts = { role = AsEnum<Role> },
)]
pub struct User {
    pub id: i64,
    pub role: Role,
}

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

#[model(
    table = "secrets",
    casts = { api_key = AsEncrypted },
)]
pub struct Secret {
    pub id: i64,
    pub api_key: String,  // runtime is plain UTF-8
}

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 suprnova::AsEncryptedObject;
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
pub struct CardOnFile {
    pub last4: String,
    pub exp_month: u8,
    pub exp_year: u16,
}

#[model(
    table = "billing",
    casts = { card = AsEncryptedObject<CardOnFile> },
)]
pub struct Billing {
    pub id: i64,
    pub card: CardOnFile,
}

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

#[model(
    table = "users",
    casts = { password = AsHashed },
)]
pub struct User {
    pub id: i64,
    pub password: String,
}

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 suprnova::{casts, AsDate, AsJson, User};

let map = casts! {
    birthday = AsDate,
    metadata = AsJson<serde_json::Value>,
};
let rows = User::query().with_casts(map).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 suprnova::{accessor, model, Model};

#[model(
    table = "users",
    appends = ["full_name"],
)]
pub struct User {
    pub id: i64,
    pub first_name: String,
    pub last_name: String,
}

impl User {
    #[accessor]
    pub fn full_name(&self) -> String {
        format!("{} {}", self.first_name, self.last_name)
    }
}

A serde_json::to_value(&user) (or user.to_json()) now contains:

{
  "id": 1,
  "first_name": "Alice",
  "last_name": "Xu",
  "full_name": "Alice Xu"
}

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:

impl Post {
    #[accessor]
    pub fn word_count(&self) -> usize {
        self.body.split_whitespace().count()
    }
}

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:

#[model(
    table = "users",
    appends = ["full_name"],
    hidden = ["first_name", "last_name"],
)]

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 serde_json::Value;
use suprnova::{model, mutator, FrameworkError, Model};

#[model(
    table = "users",
    fillable = ["password"],
    mutators = ["password"],
)]
pub struct User {
    pub id: i64,
    pub password: String,
}

impl User {
    #[mutator]
    pub fn set_password(&mut self, value: Value) -> Result<(), FrameworkError> {
        let raw: String = serde_json::from_value(value).map_err(|e| {
            FrameworkError::validation("password", format!("{e}"))
        })?;
        // Normalise + hash; AsHashed would do the hash on its own,
        // but the mutator is where you can also enforce policy.
        let trimmed = raw.trim().to_string();
        if trimmed.len() < 12 {
            return Err(FrameworkError::validation(
                "password",
                "must be at least 12 characters",
            ));
        }
        self.password = suprnova::hashing::hash(&trimmed)?;
        Ok(())
    }
}

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() on create().
  • Bumps updated_at on every save() and update(attrs).
  • Emits an impl Touchable for YourStruct so you can call .touch().await to bump updated_at without changing any other column.
use chrono::{DateTime, Utc};
use suprnova::{model, Model, Touchable};

#[model(table = "posts")]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

// Bump updated_at without other changes:
let post = Post::find_or_fail(1).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 suprnova::eloquent::without_touching;

without_touching(async {
    for post in Post::query().get().await? {
        post.touch().await?;  // no-op inside the scope
    }
    Ok::<_, suprnova::FrameworkError>(())
}).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 = Post::find_or_fail(1).await?;
let mut copy = original.replicate().await?;  // unsaved
copy.title = format!("{} (copy)", original.title);
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 suprnova::{Listener, FrameworkError};

pub struct ResetReplicatedFlags;

#[async_trait::async_trait]
impl Listener<post::events::Replicating> for ResetReplicatedFlags {
    async fn handle(&self, event: &post::events::Replicating) -> Result<(), FrameworkError> {
        let mut replica = event.replica.lock().await;
        replica.published = false;       // copies start unpublished
        replica.view_count = 0;          // counters reset
        Ok(())
    }
}

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 (PostDraft, 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 chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use suprnova::{
    accessor, hashing, model, mutator, AsBool, AsDateTime,
    AsDecimal, AsEncryptedObject, AsEnum, AsHashed, AsJson,
    AsOptionalDateTime, FrameworkError, Model,
};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardOnFile {
    pub last4: String,
    pub exp_month: u8,
    pub exp_year: u16,
}

#[derive(Debug, Clone, Copy, strum::EnumString, strum::AsRefStr)]
pub enum Role {
    Admin,
    Editor,
    Viewer,
}

#[model(
    table = "users",
    soft_deletes,
    appends = ["display_name"],
    hidden = ["password", "card"],
    fillable = ["name", "email", "password", "role", "credit"],
    mutators = ["password"],
    casts = {
        role = AsEnum<Role>,
        verified = AsBool,
        credit = AsDecimal<2>,
        card = AsEncryptedObject<CardOnFile>,
        metadata = AsJson<serde_json::Value>,
        password = AsHashed,
        last_login_at = AsOptionalDateTime,
    },
)]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub password: String,
    pub role: Role,
    pub verified: bool,
    pub credit: Decimal,
    pub card: CardOnFile,
    pub metadata: serde_json::Value,
    pub last_login_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    // deleted_at is auto-injected by soft_deletes (AsOptionalDateTime)
}

impl User {
    #[accessor]
    pub fn display_name(&self) -> String {
        if self.name.is_empty() { self.email.clone() } else { self.name.clone() }
    }

    #[mutator]
    pub fn set_password(&mut self, value: Value) -> Result<(), FrameworkError> {
        let raw: String = serde_json::from_value(value).map_err(|e| {
            FrameworkError::validation("password", format!("{e}"))
        })?;
        let trimmed = raw.trim().to_string();
        if trimmed.len() < 12 {
            return Err(FrameworkError::validation(
                "password",
                "must be at least 12 characters",
            ));
        }
        // The mutator hashes; AsHashed sees an already-hashed value
        // on subsequent saves and passes through unchanged.
        self.password = hashing::hash(&trimmed)?;
        Ok(())
    }
}

This single declaration gives you:

  • Eight typed casts wiring the storage / runtime boundary.
  • An accessor that synthesises display_name from existing columns.
  • A mutator that validates and hashes the password.
  • Auto-managed created_at / updated_at.
  • Soft deletes with an auto-injected deleted_at column.
  • 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 Crypt facade the encrypted casts share, key rotation protocol, and the wider crypto surface.
  • Events & Listeners - the dispatcher behind Replicating and the other 15 model lifecycle events.
  • Authentication - the Authenticatable trait and where AsHashed fits into the password flow.
  • Validation - FrameworkError::validation and the pattern mutators use to surface per-field errors.