Manual contentsEloquent ORMBrowse 103 chapters
Manual 16 min read

Eloquent Serialization

How Eloquent models turn into JSON. The chapter covers to_array() and to_json(), the hidden / visible / appends filter pipeline, the two terminal helpers to_array_except / to_array_only, the way appends bridge accessors into the output, and the two divergences from Laravel that catch people out: the serde-bypass footgun, and the fact that eager-loaded relations do not auto-fold into the JSON body.

If you've read Eloquent API, most names here are familiar - the attribute reference is in that chapter. This page is where the serialization contract lives: which fields appear, in what order the filters apply, and what produces a leak if you forget about it.

Table of contents

The contract

Every #[suprnova::model] struct gets two serialization methods from the Model trait:

fn to_array(&self) -> serde_json::Value;
fn to_json(&self) -> String;

to_array produces a serde_json::Value for use in handler responses and tests. to_json is a thin wrapper - serde_json::to_string(&self .to_array()) - so a single filter pipeline owns both shapes.

The output is a JSON object keyed by struct field name (or whatever serde rename you've applied), filtered through three optional knobs declared on #[model(...)]:

  • hidden = [...] - column denylist
  • visible = [...] - column whitelist (mutually exclusive with hidden)
  • appends = [...] - accessor methods to inject under named keys

When the model declares none of these, the trait default body runs: serialize self via serde_json::to_value(self), strip two framework-internal scratch fields (__eager and __pivot - see eager-loaded relations), return the result. When the model declares any of them, the macro emits an override that runs the pipeline.

to_array and to_json

The minimum useful example - a row out the door as JSON:

use suprnova::{json_response, model, Model, Request, Response};
use chrono::{DateTime, Utc};

#[model(table = "users")]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

pub async fn show(req: Request) -> Response {
    let id: i64 = req.param("id")?.parse()
        .map_err(|_| suprnova::FrameworkError::param_parse("id", "i64"))?;
    let user = User::find_or_fail(id).await?;
    json_response!(user.to_array())
}

json_response! accepts any serde_json::Value; user.to_array() produces one. The string-shaped equivalent is user.to_json() - identical body, identical filters, just one extra to_string.

You can also reach for serde_json::to_value(&user) directly. Don't do that for anything user-facing. It bypasses the filter pipeline entirely - see the serde-bypass footgun later in the chapter for why.

Hiding fields - hidden = [...]

The denylist form. Every column except the listed ones serialises:

use chrono::{DateTime, Utc};
use suprnova::{model, Model};

#[model(
    table = "users",
    fillable = ["name", "email", "password"],
    hidden = ["password", "remember_token"],
)]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub password: String,
    pub remember_token: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

The user-facing JSON for this model never contains password or remember_token:

{
    "id": 42,
    "name": "Alice",
    "email": "alice@example.com",
    "created_at": "2026-05-30T11:14:22Z",
    "updated_at": "2026-05-30T11:14:22Z"
}

hidden is the right tool when most fields go to the wire and you need to subtract a small set of secrets, internal flags, or auth-only data.

Whitelisting fields - visible = [...]

The allowlist form. Only the listed columns serialise:

#[model(
    table = "users",
    visible = ["id", "name", "avatar_url"],
)]
pub struct PublicUserView { /* ... */ }

Useful for a model that exists specifically to be a thin public projection (think Laravel's "Profile" / "PublicUser" types). visible is also the right tool when the table holds dozens of internal columns and only a few belong on the wire - listing the keep-set is shorter than listing the strip-set.

hidden and visible are mutually exclusive at compile time. The macro emits an error if you set both:

error: cannot specify both `hidden` and `visible` on the same model
 --> src/models/user.rs:7:1
  |
7 | #[model(table = "users", hidden = ["x"], visible = ["y"])]
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

The two are policy opposites - pick the one whose intent matches the shape of your model, not both.

Appending accessors - appends = [...]

appends injects computed values into the JSON output. Each entry names an #[accessor]-tagged method on the model; the macro calls it during to_array() and stores the return value under the same key.

use suprnova::{accessor, model, Model};

#[model(
    table = "users",
    fillable = ["first_name", "last_name"],
    appends = ["full_name", "initials"],
)]
pub struct User {
    pub id: i64,
    pub first_name: String,
    pub last_name: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

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

    #[accessor]
    pub fn initials(&self) -> String {
        let f = self.first_name.chars().next().unwrap_or(' ');
        let l = self.last_name.chars().next().unwrap_or(' ');
        format!("{f}{l}")
    }
}

The serialised user now carries both computed keys:

{
    "id": 7,
    "first_name": "Alice",
    "last_name": "Pond",
    "created_at": "...",
    "updated_at": "...",
    "full_name": "Alice Pond",
    "initials": "AP"
}

The macro validates appends entries at compile time:

  • Each name must parse as a Rust identifier ("full-name" fails - it's not a valid ident).
  • If the named method doesn't exist on the model's impl block, the compiler points at the macro-generated dispatcher with a clear no method named 'full_name' found error.

Calling user.full_name() directly from Rust works exactly like any other method - appends only controls the JSON dispatch table. Accessors stay regular methods.

The filter pipeline order

When a model declares any of hidden, visible, or appends, the macro emits a to_array override that runs four steps in this order:

  1. Serialise self to a serde_json::Map via serde_json::to_value.
  2. Strip the framework-internal __eager and __pivot keys unconditionally (more on these in the relations section).
  3. Apply visible as a whitelist when non-empty: every key NOT in the list is removed.
  4. Apply hidden as a denylist: every listed key that survived the whitelist is removed.
  5. Inject appends: for each entry, call the registered accessor and insert its result under the entry's name.

Why Suprnova diverges

Laravel runs the same hiddenvisibleappends ordering. The divergence is in step 5: in Suprnova, appends run after the hidden denylist, and they always show up - even if their name is also listed in hidden. The reasoning is the same as Laravel's: if you both declare $appends = ['full_name'] and $hidden = ['full_name'], the intent is "compute it and ship it" - appends is the more specific signal. The order matters when an accessor's key collides with a column name (e.g. an accessor that overrides the stored display_name column's value); the accessor wins on the wire.

Per-call filtering - to_array_except / to_array_only

For one-off cases where the column declaration doesn't fit, two terminal helpers run the full to_array pipeline then trim the result by name:

use suprnova::{json_response, Model};

pub async fn admin_show(user: User) -> suprnova::Response {
    // strip a few extra fields for an admin endpoint that needs most
    // of the row but not these:
    json_response!(
        user.to_array_except(&["password_hash", "remember_token", "internal_notes"])
    ))
}

pub async fn directory_show(user: User) -> suprnova::Response {
    // public directory - only the columns we want to publish:
    json_response!(
        user.to_array_only(&["id", "name", "avatar_url"])
    ))
}

Both produce a serde_json::Value - they don't mutate self and they don't change future serialisations of the same row. They run the full hidden / visible / appends pipeline first, then apply their own trim on top. to_array_only returns a fresh JSON object containing only the named keys; to_array_except returns the full object minus the named keys.

Why Suprnova diverges

Laravel's $user->makeHidden(['x']) and $user->makeVisible(['x']) mutate the model instance - every subsequent toArray() call, including ones that happen when the model is nested inside a parent's serialisation, sees the changed state. Suprnova's helpers are terminal. They produce a Value and stop. If you need the change to propagate, declare it on #[model(hidden = [...])] / #[model(visible = [...])] so the type expresses the policy, not a hidden mutation on the instance.

The Rust-shaped reason: an Eloquent struct in Suprnova is a plain Rust struct with no runtime attribute bag. There's no place for an instance-side visibility flag to live without adding ambient hidden state, which is the kind of footgun the framework intentionally avoids.

Conditional hiding by viewer

The idiomatic pattern when visibility depends on the viewer is a match at the call site, branching into the right per-call filter:

use suprnova::{Auth, json_response, Model, Request, Response};

pub async fn show(req: Request) -> Response {
    let id: i64 = req.param("id")?.parse()
        .map_err(|_| suprnova::FrameworkError::param_parse("id", "i64"))?;
    let user = User::find_or_fail(id).await?;
    let viewer = Auth::user_as::<User>().await?;
    let viewing_self = viewer.as_ref().map(|v| v.id) == Some(user.id);

    let body = if viewing_self {
        user.to_array()
    } else {
        user.to_array_except(&["email", "phone", "stripe_customer_id"])
    };

    json_response!(body)
}

For more elaborate per-viewer shape - different attributes for admins, trial users, paid users - the right tool is the JSON:API resource layer with Maybe<T> / MissingValue<T> fields. See JSON:API resources for the declarative form.

The serde-bypass footgun

This is the single most important thing to know about Eloquent serialization in Suprnova.

The hidden / visible / appends filters only run through to_array() and to_json(). They are not enforced by the derived Serialize impl. Returning the struct through any other serde path bypasses the filters entirely.

That means all of these leak password:

// Direct serde - bypasses to_array, hidden has no effect:
let raw = serde_json::to_value(&user).unwrap();

// json_response! with a struct field - same:
json_response!({ "user": user }))

// Nested inside another serializable container - same:
#[derive(Serialize)]
struct EnvelopeWithUser { ok: bool, user: User }
let env = EnvelopeWithUser { ok: true, user };
json_response!(env))

// Returning a Vec<User> through serde - same:
json_response!(users))   // where users: Vec<User>

Only these go through the filter pipeline:

json_response!(user.to_array()))
json_response!(users_collection.to_array()))  // Collection<User>
json_response!(user.to_array_except(&["secret"])))
json_response!(user.to_array_only(&["id", "name"])))

Why this happens

Serde's blanket Serialize for Vec<T> (and any other container) calls T::serialize directly. Suprnova's filter pipeline lives in the Model::to_array trait method, not in Serialize. The trait method doesn't get invoked unless you call it.

The framework guards against the internal footgun (__eager / __pivot scratch fields are marked #[serde(skip)] so they don't leak through either path), but the macro deliberately does not emit #[serde(skip_serializing)] on hidden fields - doing so would break legitimate uses of serde with the inner SeaORM model where a caller wants the full row (e.g. internal RPC, persistence layers, diagnostics, tests).

The rule

For any value that crosses the trust boundary back to a client, walk through to_array() or one of its filtered cousins. The four-line contract that buys you the safety:

Want Use Result
Serialise one model user.to_array() Filtered JSON object
Serialise a collection collection.to_array() Filtered JSON array
Subtract a few fields user.to_array_except(&["x"]) Filtered + subtracted
Keep only a few fields user.to_array_only(&["x"]) Only listed keys

A linter or PR-time review for json_response!\({.*: [a-z_]+ ?}) and serde_json::to_value\(&\w+\) on model values is a cheap way to keep the rule. The framework's own tests for Model serialisation cover both paths.

Serializing collections

A Collection<M> - returned by Builder::get(), Model::all(), and relation accessors - has its own to_array() and to_json() that walk the underlying Vec<M> and call per-row to_array(). The result is a JSON array of filtered objects:

use suprnova::{json_response, Model};

pub async fn list() -> suprnova::Response {
    let users = User::all().await?;
    json_response!(users.to_array())
}

This is the only place to get the per-row filter on a multi-row result. serde_json::to_value(&users) would emit a Vec via serde's blanket impl and bypass the filters on every row at once - the collection-level helper exists exactly to close that gap.

// The Collection<M> override:
pub fn to_array(&self) -> Value {
    Value::Array(self.0.iter().map(|m| m.to_array()).collect())
}

For a paginator, the wrapped data lives in LengthAwarePaginator::data / CursorPaginator::data and is a Vec<M> - call .to_array() on each item before assembling the paginator response, or use the JSON:API paginated form which handles per-row filtering as part of the resource pipeline.

Eager-loaded relations and serialization

This is the second divergence to internalise.

When you call .with(["posts"]) on a builder, the framework loads the posts and stores them in a per-row EagerLoadCache (the auto-injected __eager field). The accessor for reading them - user.posts_loaded() - pulls from that cache.

The cache is #[serde(skip)] and to_array() strips it unconditionally. Eager-loaded relations do not auto-fold into the JSON output. A to_array() on a user with eagerly-loaded posts looks identical to a to_array() on a user without.

Why Suprnova diverges

Laravel's toArray() walks $model->getRelations() and folds every loaded relation into the output. PHP's array-shaped model bag makes this natural - a relation is just another keyed entry on the model.

Rust's typed Eloquent structs don't have that bag. A User struct has typed columns, not a heterogeneous map of "whatever relations were loaded". Folding posts in would require either runtime field injection on a typed struct (a serde-bypass mechanism), or a parallel serialisation path that consults the cache after running the column serialiser. Both options would couple every model's JSON shape to which relations a particular caller eager-loaded - a contract that's load-bearing in PHP because clients learn to depend on it, and a contract Suprnova explicitly refuses to ship because it makes JSON shape depend on caller-side query construction.

The two ways to ship relation data

1. Explicit accessor + appends. Define a method that pulls from <rel>_loaded(), register it in appends. The relation shows up under whatever key you name. This works when the relation is always eager-loaded on the read path:

use suprnova::{accessor, model};
use serde_json::Value;

#[model(
    table = "users",
    appends = ["posts"],
)]
pub struct User { /* ... */ }

impl User {
    #[accessor]
    pub fn posts(&self) -> Value {
        // posts_loaded() PANICS if .with(["posts"]) wasn't called on
        // the read path. The accessor MUST run after eager-loading.
        let posts = self.posts_loaded();
        serde_json::to_value(posts).unwrap_or(Value::Null)
    }
}

// Read path MUST eager-load:
let users = User::query()
    .with(["posts"])
    .get()
    .await?;
let body = users.to_array();   // each user's "posts" key is populated

The contract is loud: forget the .with(["posts"]), and the accessor panics on the first row's posts_loaded() call (the eager cache panics on read when the relation wasn't loaded, by design - a silent empty array would hide the bug). For optional eager-load, use the HasOne form which returns Option<&T> and gives you a match:

impl User {
    #[accessor]
    pub fn profile(&self) -> Value {
        match self.profile_loaded() {
            Some(profile) => serde_json::to_value(profile).unwrap_or(Value::Null),
            None => Value::Null,
        }
    }
}

2. The JSON:API resource layer. When the relation shape and inclusion policy belong on the wire format rather than the model, use a #[derive(Data)] #[json_resource] struct with #[data(allow_include)] on the relationship field. Clients opt in via ?include=posts.comments, the framework walks the include tree, and populates included with deduplicated resource objects. This is the right answer when:

  • Relation shape is a wire-format concern (sparse fieldsets, conditional inclusion, cross-link metadata).
  • Different endpoints want different default inclusions.
  • The same model appears under different envelopes (one endpoint ships posts, another ships subscriptions).

See JSON:API resources for the full pattern.

What about JSON:API?

The to_array() pipeline and the Resource / JsonApi facade are two layers, and they serve different jobs:

Concern Model::to_array Resource::single / JsonApi::single
Shape Flat object - column names map directly to keys JSON:API envelope (data, included, meta, links, jsonapi)
Per-attribute control hidden / visible / appends on #[model] #[data(input_only)], Maybe<T>, sparse fieldsets via ?fields[type]=
Relations Manual (accessor + appends, see above) First-class via #[data(allow_include)] + ?include=
Pagination Wrap a Vec<Value> by hand Resource::paginated(p) handles links + meta
Errors Render through FrameworkError into_json_api_response() produces JSON:API errors envelope
When to reach for it Simple endpoints, internal tools, ad-hoc shapes Public APIs, third-party consumers, JSON:API-aware clients

to_array() is the lower layer - it's what gets called for most internal handlers, admin pages, Inertia props (via serde), and tests. The JSON:API layer composes on top: it doesn't replace to_array, it adds an envelope around per-resource attribute / relationship logic that's too rich to live on the model itself.

For typed Inertia props you almost always want the resource layer or a dedicated #[derive(Serialize)] DTO with explicit fields rather than piping the model through serde directly. Inertia returns get the same serde-bypass treatment as everything else - the safe path is "build a DTO, fill it from to_array(), return the DTO".

Where each piece lives

Concern File
Model::to_array / to_json trait defaults framework/src/eloquent/model.rs
Model::to_array_except / to_array_only framework/src/eloquent/model.rs
Model::__append_accessor trait default framework/src/eloquent/model.rs
Macro-emitted to_array override (filter pipeline) suprnova-macros/src/model/serialization.rs
Macro-emitted __append_accessor dispatcher suprnova-macros/src/model/serialization.rs
Collection<M>::to_array / to_json framework/src/eloquent/collection.rs
EagerLoadCache (the __eager field) framework/src/eloquent/relations/eager_cache.rs
hidden / visible / appends macro parsing suprnova-macros/src/model/parse.rs
#[accessor] function-level macro suprnova-macros/src/lib.rs

Next

  • Eloquent API - the full model surface, attribute reference, and where #[accessor] / #[mutator] are defined
  • JSON:API resources - the declarative resource layer for richer per-viewer shapes, sparse fieldsets, and compound ?include= documents
  • Validation - how request input becomes a typed struct before the model layer sees it
  • Responses - HttpResponse builders, headers, and cookies; the surface json_response! ultimately produces
  • Error Model - how an error becomes a JSON body with the same request_id correlation as the success path