You convert Eloquent models to arrays, JSON, and serde output with one visibility policy. You declare hidden fields, visible fields, and default appends on the model. You can change visibility or add an append on one instance. You include eager-loaded relations through explicit accessors or resources.
Table of contents
- The contract
to_arrayandto_json- Hiding fields -
hidden = [...] - Whitelisting fields -
visible = [...] - Appending accessors -
appends = [...] - The filter pipeline order
- Per-call filtering -
to_array_except/to_array_only - Conditional hiding by viewer
- Serde serialization
- Serializing collections
- Eager-loaded relations and serialization
- What about JSON:API?
- Where each piece lives
- Next
The contract
Every #[suprnova::model] struct gets two serialization methods from
the Model trait:
;
;
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 denylistvisible = [...]- column whitelist (mutually exclusive withhidden)appends = [...]- accessor methods to inject under named keys
You use the same policy through the model's Serialize implementation.
You exclude the framework's __eager and __pivot state on every path.
You keep persistence separate from this output policy, so hidden fields
still participate in saves.
to_array and to_json
The minimum useful example - a row out the door as JSON:
use ;
use ;
pub async
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 use serde_json::to_value(&user) or
serde_json::to_string(&user). You get the same visibility and appends
policy, including changes you make on that instance.
Hiding fields - hidden = [...]
The denylist form. Every column except the listed ones serialises:
use ;
use ;
The user-facing JSON for this model never contains password or
remember_token:
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:
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 ;
The serialised user now carries both computed keys:
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
implblock, the compiler points at the macro-generated dispatcher with a clearno method named 'full_name' founderror.
Calling user.full_name() directly from Rust works exactly like any
other method - appends only controls the JSON dispatch table.
Accessors stay regular methods.
You select a runtime append with append(name)?. You register that name
in accessors = [...] when you do not want it in the default appends.
You add a name once, and an unknown name returns an error. You keep the
append on the instance for every subsequent output.
The filter pipeline order
You get the same steps for serde, to_array, and to_json:
- You serialize the model's runtime attributes, preserving serde field options.
- You exclude
__eagerand__pivot. - You retain only visible names when the visible list is non-empty.
- You remove hidden names.
- You apply those lists to default and runtime appended names before calling their accessors. You insert only permitted accessor values.
You suppress an appended name when it is hidden or outside a non-empty
visible list. You do not evaluate a suppressed accessor. An allowed accessor
with the same name as a stored column replaces that column in the output.
Serde returns accessor serialization errors. The existing infallible
to_array and to_json helpers represent serialization failure as JSON null.
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 ;
pub async
pub async
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.
Instance visibility
You change every later conversion of one instance with these methods:
user.make_hidden;
user.make_hidden_if;
user.make_visible;
user.make_visible_if;
You pass one name, an array, a vector, or a slice. Each method returns
&mut Self so you can chain changes. A false condition leaves the lists
unchanged. You remove a name from hidden with make_visible. When you
already have a non-empty visible list, you also add that name to visible.
You keep the changes on the instance and its clones. You do not change
other instances or the model's declared defaults.
You still cannot declare both hidden and visible on one model.
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 ;
pub async
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.
Serde serialization
You use direct serde serialization for model values and nested model values:
let value = to_value?;
let body = to_string?;
let nested = to_value?;
You get the same filtered attributes and appends on each path. You retain
serde renames and field serialization options. You can still trim one output
with to_array_except or to_array_only, without changing the instance.
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 ;
pub async
You get the same per-model policy from serde_json::to_value(&users).
You also get it when models appear in a paginator's data vector or another
serde container. You can use the JSON:API paginated form
when you need its resource envelope, links, and metadata.
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 ;
use Value;
// Read path MUST eager-load:
let users = query
.with
.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:
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 shipssubscriptions).
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.
You can pass models through serde into typed Inertia props with their visibility policy intact. You use a resource or a dedicated DTO when you need a different response shape.
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 |
appends accessor dispatch, trait default |
framework/src/eloquent/model.rs |
Macro-emitted Serialize and unfiltered attribute view |
suprnova-macros/src/model/serialization.rs |
appends accessor dispatch, macro-emitted |
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 -
HttpResponsebuilders, headers, and cookies; the surfacejson_response!ultimately produces - Error Model - how an error becomes a JSON body
with the same
request_idcorrelation as the success path
