Manual contentsEloquent ORMBrowse 103 chapters
Manual 18 min read

Eloquent collections

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

Any terminal that returns more than one row hands you a Collection<M>:

use suprnova::{Collection, Model};

let users: Collection<User> = User::all().await?;
let admins: Collection<User> = User::query()
    .db_where("role", "=", "admin")
    .get()
    .await?;
let recent: Collection<User> = User::query()
    .order_by_desc("created_at")
    .limit(50)
    .get()
    .await?;

You can also wrap any Vec<T> you already have:

let from_vec: Collection<User> = users_vec.into();
let from_vec2: Collection<User> = Collection::from_vec(users_vec);
let empty: Collection<User> = Collection::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.

impl<T> Collection<T> { /* generic methods - work for any T */ }

impl<M> Collection<M> where M: Model { /* string-keyed model methods */ }

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

let nums: Collection<i32> = Collection::from_vec(vec![3, 1, 4, 1, 5, 9, 2, 6]);

nums.len();                         // 8
nums.is_empty();                    // false
nums.is_not_empty();                // true
nums.first();                       // Some(&3)
nums.last();                        // Some(&6)
nums.first_where(|n| **n > 3);      // Some(&4)
nums.last_where(|n| **n > 3);       // Some(&6)
nums.contains(&4);                  // true - from Deref<Target = [T]>
nums.contains_where(|n| *n > 5);    // 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: Collection<i32>      = nums.clone().map(|n| n * 2);
let evens:   Collection<i32>      = nums.clone().filter(|n| n % 2 == 0);
let odds:    Collection<i32>      = nums.clone().reject(|n| n % 2 == 0);
let unique:  Collection<i32>      = nums.clone().unique();
let chunks:  Vec<Collection<i32>> = nums.clone().chunk(3);
let taken:   Collection<i32>      = nums.clone().take(4);
let skipped: Collection<i32>      = nums.clone().skip(2);
let middle:  Collection<i32>      = nums.clone().slice(2, 4);
let flipped: Collection<i32>      = nums.clone().reverse();
let shuffled: Collection<i32>     = nums.clone().shuffle();

map changes the element type:

let labels: Collection<String> = nums.clone().map(|n| format!("n={n}"));

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(|n| tracing::debug!(value = n, "processing"))
    .filter(|n| *n > 2)
    .take(3);

Closure-keyed grouping and sorting

use std::collections::HashMap;

// Bucket items by closure-derived key.
let by_parity: HashMap<bool, Collection<i32>> =
    nums.clone().group_by_with(|n| n % 2 == 0);

// Index items by closure-derived key (later duplicates overwrite).
let by_value: HashMap<i32, i32> =
    nums.clone().key_by_with(|n| *n);

// Sort by closure-derived comparator.
let sorted_desc: Collection<i32> =
    nums.clone().sort_with(|a, b| b.cmp(a));

// Deduplicate by closure-derived key.
let unique_mod3: Collection<i32> =
    nums.clone().unique_by(|n| n % 3);

// Project every item by closure into a new collection.
let strs: Collection<String> =
    nums.pluck_by(|n| n.to_string());

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(0, |acc, n| acc + n);  // 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 = Collection::from_vec(vec![1, 2, 3, 4]);
let b = Collection::from_vec(vec![3, 4, 5, 6]);

let joined = a.clone().concat(b.clone());    // [1,2,3,4,3,4,5,6]
let same   = a.clone().merge(b.clone());     // alias of concat
let only_a = a.clone().diff(b.clone());      // [1,2]
let common = a.clone().intersect(b.clone()); // [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: Option<&i32>     = nums.random();        // borrow one
let many: Collection<i32> = nums.clone().random_n(3); // 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 suprnova::{Collection, Model};

let users: Collection<User> = User::query().get().await?;

let emails: Collection<String> = users.pluck::<String>("email");
let ids:    Collection<i64>    = users.pluck::<i64>("id");

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 std::collections::HashMap;

let email_by_id: HashMap<i64, String> =
    users.pluck_keyed::<i64, String>("id", "email");

Later rows overwrite earlier ones for the same key.

Grouping and indexing

use std::collections::HashMap;

let by_role: HashMap<String, Collection<User>> = users.group_by("role");
let by_id:   HashMap<String, User>             = users.key_by("id");

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: HashMap<i64, User> = users.key_by_with(|u| u.id);

Filtering

The model-aware where_* methods take serde_json::Value because they compare against the JSON-encoded form of the column:

use serde_json::json;

let active: Collection<User>  = users.clone().where_eq("active", json!(true));
let admins: Collection<User>  = users.clone()
    .where_in("role", vec![json!("admin"), json!("owner")]);
let non_guests: Collection<User> = users.clone()
    .where_not_in("role", vec![json!("guest")]);

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:  Collection<User> = users.clone().sort_by("name");
let by_name_desc: Collection<User> = users.clone().sort_by_desc("name");

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.sum::<f64>("balance");
let avg:   Option<f64>   = users.avg::<f64>("balance");
let lo:    Option<i64>   = users.min::<i64>("login_count");
let hi:    Option<i64>   = users.max::<i64>("login_count");

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: Collection<User> = User::query().get().await?;
users.load(["posts.comments"]).await?;

for u in &users {
    for p in u.posts_loaded() {
        println!("{}: {} comments", p.title, p.comments_loaded().len());
    }
}

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: Collection<User> = User::query().with(["posts"]).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(["posts.comments"]).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 = serde_json::to_string(&users)?;

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: serde_json::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: Collection<User> = User::all().await?;

// Borrowing reads first - the collection is still alive after each.
let total       = users.sum::<f64>("balance");
let avg         = users.avg::<f64>("balance");
let count_admin = users.iter().filter(|u| u.role == "admin").count();
let emails      = users.pluck::<String>("email");

// Now consume.
let admins: Collection<User> = users.where_eq("role", json!("admin"));

Collection vs Vec

The wrapper is intentionally thin. The conversion routes go both ways and stay cheap:

let v: Vec<User>          = User::query().get().await?.into_vec();
let c: Collection<User>   = Collection::from(v);
let c2: Collection<User>  = Collection::from_vec(c.clone().into_vec());

Deref<Target = [T]> gives you every slice method automatically. That includes:

let users: Collection<User> = User::all().await?;

users.len();             // slice method
users.iter();            // slice method
users[0].name.clone();   // slice indexing
users.contains(&u);      // slice method
users.binary_search(&u); // slice method
&users[1..4];            // 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 {           // iter by &User
    /* ... */
}

for user in users.clone() {    // iter by User (consumes)
    /* ... */
}

DerefMut only yields &mut [T] - a slice, not a Vec. That means in-place mutation of element fields works:

let mut users: Collection<User> = User::all().await?;
for u in users.iter_mut() {
    u.last_seen_at = Some(Utc::now());
}

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(new_user);
let users: Collection<User> = Collection::from(v);

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

let mut stream = User::query().lazy();
while let Some(row) = stream.next().await {
    let user = row?;
    println!("{}", user.email);
}
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:

  • each returns Self instead of &self so a side-effect call (logging, metrics) doesn't break a chain. PHP's each runs for-effect and returns the collection; you couldn't do $c->each(...)->filter(...) cleanly without re-fetching. In Rust we move self through, 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 / max take 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 into T are silently skipped (matching Laravel's missing-key behaviour), but you pick the type intentionally.

  • Deref<Target = [T]>, not Deref<Target = Vec<T>>. A Collection is conceptually a "snapshot of rows", not a mutable buffer. Slice methods come through Deref; if you want push/pop, into_vec() gives you the raw Vec and removes any pretence.

  • Serialisation diverges in service of correctness. to_array and to_json route through Model::to_array() so per-model hidden/visible/appends apply; serde's blanket Serialize for Vec bypass is documented as the footgun it is. Laravel's toArray() does the same routing; we just have to name the gap explicitly because Rust users will reach for serde_json::to_string by 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 IntoJsonResource with 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 Collection for downstream processing.
  • Testing - patterns for asserting on collection contents (length, contained elements, ordering) inside handler and model tests.