Collection<T> is Suprnova's Laravel-shape collection type - the
return value of Builder::get, Model::all, every pluck, every
relation-load terminal that yields more than one row. It is a thin
wrapper around Vec<T> that derefs to &[T], so every existing
slice method (.len(), .iter(), indexing, .contains(&v)) works
without change. Layered on top is the Laravel surface: map,
filter, pluck, group_by, sort_by, where_eq, sum, avg,
the lot.
This chapter is the standalone reference for the collection surface.
The parent Eloquent API summarises it; this chapter
goes through every method, the borrow-vs-consume contract, the
serialization rule that bites if you skip it, and when to drop down
to Vec<T> instead.
Table of contents
- Where collections come from
- The two impl blocks
- Generic surface - works on any
Collection<T> - Model-aware surface -
Collection<M>whereM: Model - Eager loading on a collection
- Serialization -
to_arrayvs serde - Borrow vs consume
- Collection vs
Vec LazyCollection<M>- streaming results- Why Suprnova diverges
- Next
Where collections come from
Any terminal that returns more than one row hands you a
Collection<M>:
use ;
let users: = all.await?;
let admins: = query
.db_where
.get
.await?;
let recent: = query
.order_by_desc
.limit
.get
.await?;
You can also wrap any Vec<T> you already have:
let from_vec: = users_vec.into;
let from_vec2: = from_vec;
let empty: = new;
Collection<T> implements Default, Clone, Serialize,
Deserialize, PartialEq, and IntoIterator (both by-value and by
&). It is Send when T: Send.
The two impl blocks
The methods on Collection split into two families based on the type
parameter.
The generic block gives you map, filter, reject, chunk,
first, last, unique, and a closure-based version of every
column accessor (pluck_by, group_by_with, sort_with,
key_by_with). These work on Collection<i32>,
Collection<String>, Collection<MyDto>, anything.
The model-aware block adds string-keyed sugar (pluck("name"),
group_by("role"), sort_by("created_at"), sum::<f64>("balance"))
that routes per-row through the macro-emitted Model::field_value
accessor. These only exist when T implements Model.
Pick the closure form when you can - the type checker validates the field access. Pick the string-keyed form when you're matching Laravel's syntax, or when the column name is a runtime value.
Generic surface - works on any Collection<T>
Reading
use Collection;
let nums: = from_vec;
nums.len; // 8
nums.is_empty; // false
nums.is_not_empty; // true
nums.first; // Some(&3)
nums.last; // Some(&6)
nums.first_where; // Some(&4)
nums.last_where; // Some(&6)
nums.contains; // true - from Deref<Target = [T]>
nums.contains_where; // true
first_where / last_where take &&T because the predicate runs
through Iterator::find on Iter<'_, T>. Dereference twice (**n).
Transforming - consume self, return new collection
let doubled: = nums.clone.map;
let evens: = nums.clone.filter;
let odds: = nums.clone.reject;
let unique: = nums.clone.unique;
let chunks: = nums.clone.chunk;
let taken: = nums.clone.take;
let skipped: = nums.clone.skip;
let middle: = nums.clone.slice;
let flipped: = nums.clone.reverse;
let shuffled: = nums.clone.shuffle;
map changes the element type:
let labels: = nums.clone.map;
each runs a side effect and keeps the collection for further
chaining (Suprnova diverges from Laravel here on purpose - see below):
let kept = nums.clone
.each
.filter
.take;
Closure-keyed grouping and sorting
use HashMap;
// Bucket items by closure-derived key.
let by_parity: =
nums.clone.group_by_with;
// Index items by closure-derived key (later duplicates overwrite).
let by_value: =
nums.clone.key_by_with;
// Sort by closure-derived comparator.
let sorted_desc: =
nums.clone.sort_with;
// Deduplicate by closure-derived key.
let unique_mod3: =
nums.clone.unique_by;
// Project every item by closure into a new collection.
let strs: =
nums.pluck_by;
The *_with / *_by suffix is the universal "this method takes a
closure" naming convention across the generic block. The
model-aware block drops the suffix and takes a column name string
instead.
Folding and aggregating
let sum: i32 = nums.clone.reduce; // 31
For typed numeric aggregates on model collections, see sum / avg
/ min / max in the model-aware section - they work on any field
that deserialises to a numeric type.
Set operations
let a = from_vec;
let b = from_vec;
let joined = a.clone.concat; // [1,2,3,4,3,4,5,6]
let same = a.clone.merge; // alias of concat
let only_a = a.clone.diff; // [1,2]
let common = a.clone.intersect; // [3,4]
concat / merge are aliases - Laravel ships both names. diff /
intersect are O(n*m); if you have large collections, project to a
HashSet first.
Random sampling
let one: = nums.random; // borrow one
let many: = nums.clone.random_n; // pick 3
Both use the thread-local RNG (rand::rng()). Pass through a seeded
RNG manually if you need determinism in tests.
Model-aware surface - Collection<M> where M: Model
These methods only exist when the contained type is a Suprnova
model. They route per-row reads through the macro-emitted
Model::field_value(name) accessor, which returns
Option<serde_json::Value>. Rows whose field doesn't exist or
doesn't deserialise into the target type are silently skipped -
matching Laravel's missing-key behaviour.
Projection
use ;
let users: = query.get.await?;
let emails: = users.;
let ids: = users.;
pluck borrows (&self), so the original collection is still
available afterwards. The typed parameter (::<String>) is the
target type the JSON value gets deserialised into.
pluck_keyed produces a HashMap<K, V> from two columns:
use HashMap;
let email_by_id: =
users.;
Later rows overwrite earlier ones for the same key.
Grouping and indexing
use HashMap;
let by_role: = users.group_by;
let by_id: = users.key_by;
Both methods stringify the column value into a String key. A
numeric id column comes through as "1" / "2" - matching
Laravel's groupBy('team_id') contract where the output is always
string-keyed regardless of the underlying type.
If you want typed keys, use the closure form on the generic block:
let by_id: = users.key_by_with;
Filtering
The model-aware where_* methods take serde_json::Value because
they compare against the JSON-encoded form of the column:
use json;
let active: = users.clone.where_eq;
let admins: = users.clone
.where_in;
let non_guests: = users.clone
.where_not_in;
where_eq and where_in drop rows whose field_value returns
None. where_not_in keeps rows where the field is missing - the
negation of "in the set" is "not in the set OR absent".
Sorting
let by_name_asc: = users.clone.sort_by;
let by_name_desc: = users.clone.sort_by_desc;
Comparison is best-effort across JSON value shapes: numeric vs
numeric and string vs string sort cleanly within their kind; mixed
heterogeneous columns fall back to Ordering::Equal. None sorts
before any present value (mirrors Postgres NULL FIRST for ASC).
Both methods clone the underlying Vec<M> before sorting because the
comparator borrows m.field_value(field) while sort_by needs
&mut [M]. If you have a tight loop, sort with sort_with on the
generic block instead - it operates in place.
Aggregates
let total: f64 = users.;
let avg: = users.;
let lo: = users.;
let hi: = users.;
sum returns T::default() when no row contributes a value (zero
for numeric types). The other three return None so the caller
doesn't divide by zero or compare against a phantom default.
The typed parameter (::<f64>) is the JSON deserialisation target.
Pick the widest numeric type your column reasonably uses -
i64 for integer columns, f64 for decimal/float, chrono::DateTime<Utc>
for timestamps, etc.
Eager loading on a collection
When you already have a Collection<M> and want to load relations
onto every row, use load / load_missing:
let mut users: = query.get.await?;
users.load.await?;
for u in &users
Both methods take &mut self (they mutate the per-row eager-cache)
and async. Both accept the same dotted-path syntax
Builder::with([...]) accepts - "posts", "posts.comments",
"posts.comments.author".
load_missing partitions per row. Rows that already have the
relation cached are left alone; rows that don't get the bulk-load:
let mut users: = query.with.get.await?;
// Some rows already have posts cached. load_missing only touches the
// rest - and recurses into already-cached posts for `comments`.
users.load_missing.await?;
The recursion runs at every segment of a longer dotted path. With
"a.b.c", each row is partitioned at every level: a is loaded only
where missing, then for the rows that already had a, b is loaded
only where missing on those as, etc.
Both methods honour #[model(connection = "...")] routing - they
resolve the same connection the row was originally loaded from.
Serialization - to_array vs serde
This is the one footgun in the collection surface. Read it carefully.
Collection<T> derives Serialize. So this works:
let json: String = to_string?;
But - serde's blanket Serialize for Vec<T> implementation calls
T::serialize directly on every element. That bypasses the
Model::to_array() override the #[suprnova::model] macro emits.
Which means it bypasses your hidden = ["password"],
visible = [...], and appends = [...] model attributes.
If your model has hidden fields, do not serialise the
collection through serde. Use to_array() or to_json():
let value: Value = users.to_array;
let body: String = users.to_json;
Both methods route through Model::to_array() for every row, so
the per-model filter pipeline applies - hidden fields stay hidden,
visible-allowlists are enforced, accessor-driven appends show up.
The same caveat applies to anything that calls
serde_json::to_value(&collection) under the hood: Inertia::render
when you stuff a collection into props, JsonApi/Resource if you
hand them raw models instead of resource structs, log shippers that
serde-encode their payloads. The safe pattern is to convert through
a resource type (JSON:API resources) or
through to_array() before the value hits any serde codepath.
For collections of non-model types (Collection<MyDto>,
Collection<String>) the serde path is fine - the issue only
applies when T is a #[suprnova::model] struct with declared
hidden/visible/appends.
Borrow vs consume
The methods split cleanly into two contracts:
| Takes | Methods |
|---|---|
&self (borrow) |
len, is_empty, is_not_empty, first, last, first_where, last_where, contains_where, random, as_slice, pluck_by, pluck, pluck_keyed, group_by, key_by, sum, avg, min, max, to_array, to_json |
self (consume) |
map, filter, reject, each, reduce, chunk, take, skip, slice, reverse, shuffle, random_n, unique, unique_by, sort_with, sort_by, sort_by_desc, where_eq, where_in, where_not_in, concat, merge, diff, intersect, group_by_with, key_by_with, map_to_map |
&mut self |
load, load_missing |
If you want to keep the collection after a consuming call, .clone()
before the call. Collection<T>: Clone when T: Clone.
A practical pattern: read first, then transform last:
let users: = all.await?;
// Borrowing reads first - the collection is still alive after each.
let total = users.;
let avg = users.;
let count_admin = users.iter.filter.count;
let emails = users.;
// Now consume.
let admins: = users.where_eq;
Collection vs Vec
The wrapper is intentionally thin. The conversion routes go both ways and stay cheap:
let v: = query.get.await?.into_vec;
let c: = from;
let c2: = from_vec;
Deref<Target = [T]> gives you every slice method automatically.
That includes:
let users: = all.await?;
users.len; // slice method
users.iter; // slice method
users.name.clone; // slice indexing
users.contains; // slice method
users.binary_search; // slice method
&users; // slice subscripting
IntoIterator is implemented twice - for Collection<T> (by value)
and &Collection<T> (by reference), so both of these work:
for user in &users
for user in users.clone
DerefMut only yields &mut [T] - a slice, not a Vec. That means
in-place mutation of element fields works:
let mut users: = all.await?;
for u in users.iter_mut
But owned Vec mutation (push, pop, clear, truncate) is not
available on the collection directly - call into_vec() first:
let mut v = users.into_vec;
v.push;
let users: = from;
That's deliberate. The Laravel surface treats a collection as an
immutable snapshot you transform with chained methods; owned mutation
of the inner sequence is the Vec contract, not the Collection
contract.
When to drop to Vec
Reach for into_vec() when:
- You need
Vec-specific methods (push,pop,swap_remove,drain,with_capacity). - You're handing the data off to an API that takes
Vec<T>by value and you don't want the wrapper in the signature. - You're storing the rows long-term in your own struct and the Laravel surface buys you nothing.
For everything else - handler returns, transformations, Inertia
props (as long as you respect the serialization rule) -
keep the Collection<T>.
LazyCollection<M> - streaming results
Collection<M> materialises every row in memory. For datasets too
large to fit, the builder offers three streaming terminals that
return LazyCollection<M> instead:
use Model;
let mut stream = query.lazy;
while let Some = stream.next.await
| Method | Strategy |
|---|---|
Builder::lazy() |
PK-cursor pagination with the default batch size (1000) |
Builder::lazy_by_id(n) |
PK-cursor pagination with batch size n |
Builder::cursor() |
Laravel alias for lazy() |
LazyCollection<M> is a Pin<Box<dyn Stream<Item = Result<M, FrameworkError>> + Send>>
underneath, but exposes .next().await directly so you don't need
to import futures::StreamExt. Each .next() triggers the next row
delivery; the underlying batched fetch only runs when the in-batch
buffer drains, so a slow consumer doesn't accumulate rows.
The wrapper is Send (so it crosses tokio::spawn) but not
Sync - it's a single-consumer stream by construction.
See Eloquent - chunking and lazy iteration for the full guidance on which streaming pattern to pick.
Why Suprnova diverges
Laravel's Illuminate\Support\Collection is mutable: $c->filter(...)
modifies the inner array of the same object and returns $this for
chaining. PHP doesn't have ownership, so that contract is invisible.
Rust does have ownership, and pretending it doesn't would make the
collection surface dishonest. Suprnova picks the value-semantic
shape instead: every transformation consumes self and returns a
new Collection. You see the cost in your own code - if you want
to keep the original, you .clone(). If you don't, you don't.
That choice cascades through the rest of the surface:
-
eachreturnsSelfinstead of&selfso a side-effect call (logging, metrics) doesn't break a chain. PHP'seachruns for-effect and returns the collection; you couldn't do$c->each(...)->filter(...)cleanly without re-fetching. In Rust we moveselfthrough, keeping the chain fluent. -
Closure-keyed alternatives to every string-keyed method.
pluck_by,group_by_with,key_by_with,sort_with,unique_by,map_to_map,contains_where. The closures let you read fields the type checker validates instead of strings the compiler can't see. The string-keyed forms exist for Laravel-syntax parity and for runtime-decided column names. -
sum/avg/min/maxtake typed::<T>parameters. Laravel's PHP version casts on the fly; in Rust, the deserialisation target is part of the call. Rows whose value doesn't round-trip intoTare silently skipped (matching Laravel's missing-key behaviour), but you pick the type intentionally. -
Deref<Target = [T]>, notDeref<Target = Vec<T>>. ACollectionis conceptually a "snapshot of rows", not a mutable buffer. Slice methods come throughDeref; if you wantpush/pop,into_vec()gives you the rawVecand removes any pretence. -
Serialisation diverges in service of correctness.
to_arrayandto_jsonroute throughModel::to_array()so per-model hidden/visible/appends apply; serde's blanketSerialize for Vecbypass is documented as the footgun it is. Laravel'stoArray()does the same routing; we just have to name the gap explicitly because Rust users will reach forserde_json::to_stringby reflex.
The trade-off is exactly the one Suprnova makes everywhere: Laravel's surface shape, Rust's value semantics.
Next
- Eloquent API - the parent chapter, with the query builder, relations, scopes, and the full model lifecycle.
- JSON:API resources - resource structs
serialise collections through
IntoJsonResourcewith sparse fieldsets and?include=chains; the right shape for any collection that leaves your API. - Frontend - Inertia responses - the rules for handing collections to Inertia props without tripping the serialisation footgun.
- Validation - request payloads frequently produce
vectors that you wrap into
Collectionfor downstream processing. - Testing - patterns for asserting on collection contents (length, contained elements, ordering) inside handler and model tests.
