Manual contentsEloquent ORMBrowse 126 chapters
Manual 28 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.

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("role");
let original_note = user.get_raw_original_or("note", "missing");

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.

#[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

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 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::NaiveDate ↔ TEXT (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.

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:

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

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.

#[model(
    table = "orders",
    casts = {
        created_at = AsNativeDateTime,
        updated_at = AsNativeDateTime,
    },
)]
pub struct Order {
    pub id: i64,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

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:

#[model(
    table = "imports",
    casts = {
        // A text column, in a package whose datetime_cast is "native".
        received_at = AsDateTime,
    },
)]
pub struct Import {
    pub id: u64,
    pub received_at: chrono::DateTime<chrono::Utc>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

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

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

#[model(table = "events", casts = { metadata = AsOptionalJson<serde_json::Value> })]
pub struct Event {
    pub id: i64,
    pub metadata: Option<serde_json::Value>,
}

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 suprnova::{AsNativeJson, AsOptionalNativeJson};

#[model(
    table = "documents",
    casts = {
        prefs = AsNativeJson<Prefs>,
        tags = AsOptionalNativeJson<Vec<String>>,
    },
)]
pub struct Document {
    pub id: i64,
    pub prefs: Prefs,
    pub tags: Option<Vec<String>>,
}

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.

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 suprnova::{AsEnumCollection, model};

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

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

Runtime appends

You register an accessor for runtime selection with accessors = [...]. You add it to one model instance with append(name):

#[model(table = "users", accessors = ["full_name"])]
pub struct User { /* columns */ }

user.append("full_name")?;
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:

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"],
)]

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