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.
model_keys is the primary-key shortcut, and the only projection that
returns a plain Vec rather than a Collection:
let users: = query.get.await?;
let ids: = users.model_keys;
It reads the already-hydrated key field, so it costs no query. When you
only want the keys and haven't loaded the rows yet, use the builder
terminal instead - User::query().model_keys().await? projects the key
column without hydrating anything. Vec rather than Collection
matches Laravel's modelKeys(), and keeps the two halves of the pair
agreeing on one shape.
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 NULLS 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.
