Seeders populate the database with fixture data - the rows your app needs before a real user has done anything. Think a default admin account, the canonical list of countries, the demo posts on the staging environment, the 50 users + 200 posts your local dev iteration loop depends on. They are the runtime sibling of migrations: migrations build the empty schema, seeders fill it.
A seeder is a zero-sized type that implements the Seeder trait. The framework
keeps an ordered process-global registry; the per-project console db:seed
command runs every registered seeder in registration order, or one specific
seeder via --class=<Name>. Most seeders end up being a few lines that call
a model factory and let the factory do the row-generation work.
use ;
use crateUserFactory;
;
Register it once at boot:
// src/bootstrap.rs
;
Then:
# running seeder UsersSeeder
# (50 rows inserted)
That's the whole loop. The rest of this chapter covers the layout conventions,
the bigger registry composition patterns, the --class targeting flag, the
factory integration, the without_events escape hatch, and the
when-to-seed-vs-migrate-vs-factory call.
Writing a seeder
A seeder is a unit type plus a Seeder impl. name() is the registry key
(also what db:seed --class=<Name> matches against), and run() is the
async fn that performs the inserts.
// src/seeders/users_seeder.rs
use ;
use crateUserFactory;
;
Seeder is re-exported at the crate root, so use suprnova::Seeder is
enough - you do not need to reach into suprnova::seed::Seeder. async_trait
is also re-exported (use suprnova::async_trait) because the trait method
returns a future and Rust does not yet allow async fn in traits without it.
The FrameworkError return type is the same error envelope every other async
surface in the framework uses; bubbling the ? out of a factory call or a
Model::create is the expected shape. See Error Model for
the full taxonomy.
Layout convention
Mirror Laravel's database/seeders/ directory, but at the source root:
src/
├── bootstrap.rs
├── factories/
│ ├── mod.rs
│ ├── user_factory.rs
│ └── post_factory.rs
├── seeders/
│ ├── mod.rs // pub mod base_seeder; pub use base_seeder::BaseSeeder;
│ └── base_seeder.rs // Seeder impl, registered in bootstrap.rs
└── …
Generate the file by hand - there is no make:seeder generator (this is a
file with about ten lines of boilerplate). The factories the seeder calls
into get the same treatment.
A seeder that runs other seeders
The Laravel idiom of a single top-level DatabaseSeeder::run that orchestrates
the per-model seeds works here too. Instead of registering five small seeders
in bootstrap and trusting their registration order, register one composite
seeder and call the rest of them yourself:
use ;
use crate;
;
This is the recommended default. It keeps the dependency order
(users before posts) inside the seeder rather than scattered across the
bootstrap file, and db:seed --class=BaseSeeder is a single-target invocation
that runs the whole bundle.
If you want to chain seeders by name rather than by direct factory call, use
seed::run_one from inside the composite seeder:
async
The sub-seeders still need to be registered in bootstrap.rs for run_one
to find them.
The seeder registry
The framework keeps a process-global ordered map (IndexMap<String, fn() -> _>)
of every registered seeder. Three knobs control it.
register::<S>()
Add a seeder to the registry under its Seeder::name():
;
Two things to know about the registry:
- Order matters.
run_allvisits seeders in the order they were registered. IfBneeds rows fromA, registerAfirst. - Re-registering a name replaces in place. The slot keeps its original position, the function pointer changes. This is intentional - it lets a test bind a stub seeder over the real one without shifting the order. In production code, register each seeder exactly once at boot.
run_all()
Run every registered seeder in registration order. This is what the bare
console db:seed invocation calls.
run_all.await?;
Stops on the first error. Seeders that already ran are not rolled back -
run_all does not wrap a transaction around the batch because most seeders
span multiple statements and many backends do not nest transactions cleanly.
If you need rollback semantics, open the transaction inside the seeder and
keep all its work inside that scope.
run_one(name)
Run one named seeder without running the others. This is the engine for
db:seed --class=<Name> and is also useful from one-off scripts:
run_one.await?;
Misses return FrameworkError::not_found("no seeder registered for \X`")`.
The console command propagates that to a non-zero exit and a stderr line -
no silent no-op.
count() and is_registered(name)
Two read helpers, both useful in tests that assert "bootstrap wired up the expected seeders":
assert_eq!;
assert!;
Both return zero / false on a poisoned registry lock (after logging an error), which keeps tests deterministic in the face of an upstream panic.
The db:seed command
db:seed is a framework-provided console command - it ships with the
framework and lands in your project's console binary automatically through
the same inventory registry that picks up your own #[command]s. See
Console for the binary's mechanics; this section covers the
seeder-specific surface.
Run everything
Runs every registered seeder in order. On an empty registry it prints a
warning to stderr (db:seed: no seeders registered - nothing to run) and
exits zero - that's correct behavior for "someone ran the command before
registering anything" and keeps test suites that haven't seeded anything
specific from failing.
Run one seeder
Three accepted forms, in increasing order of how Laravel-shaped they feel:
All three look the seeder up in the registry by exact name and run it. An unknown name fails fast:
# Error: no seeder registered for `NotARealSeeder`
# (exit 1)
A malformed flag (--class with no following value, --class= with empty
value, --class --force) fails fast too, with a diagnostic that names the
expected shape.
From a built binary
In a containerized or systemd-managed deployment, the console binary lives at
target/release/console (or wherever your release artifact lands). Same
syntax, no cargo in front:
The console binary calls suprnova::console::dispatch_argv(std::env::args()),
which routes through the same registry as cargo run --bin console --. There
is no separate dispatch path for built artifacts.
Composing with factories
Seeders almost always end up calling factories. The factory trait knows how to build a randomized instance of one model; the seeder sequences the factory calls and any non-randomizable wiring (deterministic admin credentials, joined-table rows, file uploads).
The minimal factory + seeder pair:
// src/factories/user_factory.rs
use Factory;
use crateUser;
;
// src/seeders/users_seeder.rs
use ;
use crateUserFactory;
;
The fluent builder lives on FactoryBuilder<M>; what you can chain before
create_many matches Laravel:
// Build one persisted row with overrides:
let admin = new
.with
.with
.create
.await?;
// Build N persisted rows, all admins:
times
.with
.create_many
.await?;
// Conditional state - applies the closure only when the flag is set:
times
.when
.create_many
.await?;
make / make_one / make_many are the in-memory siblings (no insert) for
unit tests that don't want a database round-trip. See the
Eloquent chapter for the full factory surface (including
prepend, Sequence, and the #[derive(Factory)] macro that generates the
marker struct from a #[factory(model = "…")] attribute).
Idempotency is the seeder's responsibility
run_all does not snapshot or wrap a transaction; if a seeder inserts
unconditionally, re-running it produces duplicates. The two standard ways to
make a seeder safe to re-run:
- Reset first. Local dev's "wipe and reseed" loop usually does
suprnova migrate:fresh && cargo run --bin console -- db:seed-migrate:freshdrops and rebuilds every table, so the seeder always starts from empty. This is the shape most projects use day to day. - Upsert / check-first. For a seeder that must coexist with existing data (a default admin account in production, the canonical list of countries), guard the insert with a lookup or use an upsert query.
async
Muting model events with without_events
A seeder that calls Model::create in a loop fires every lifecycle event -
Creating, Saving, Created, Saved - on every row. That wakes any
registered Observer<M>, runs any queued broadcast listeners, and can
incidentally enqueue a hundred background jobs you don't actually want.
seed::without_events is the Laravel-WithoutModelEvents analogue:
use ;
use crateUser;
;
While the inner future is awaiting, both the cancellable veto path
(dispatch_cancellable) and the after-event fanout (dispatch_after)
short-circuit to Ok(()). Observers are silent, the broadcaster doesn't
wake, downstream jobs don't enqueue.
The effect is task-scoped - only work performed inside fut is muted.
Concurrent work on other tasks (HTTP request handlers, queue workers running
in the background, other seeders) continues to fire events normally. Nested
calls compose: an inner without_events block inherits the outer flag.
Factories already bypass model events
Worth knowing because it changes when you reach for without_events:
factories persist via ActiveModelTrait::insert (the Persistable impl
on the SeaORM model), which does not go through the Model trait's
create / save methods. There is no model-event dispatch to mute on a
factory-driven path. seed::without_events is for code that drives the
Model trait directly - typically because you need the runtime-shape
ergonomics that factories sidestep, or because you're touching a model
mid-seed that an observer is supposed to react to in production but not
during a fixture load.
In practice: if your seeder is a stack of UserFactory::new().create_many()
calls, you don't need without_events. If it's a hand-rolled loop of
User::create(attrs), you probably do.
Using seeders in tests
The same registry the console binary drives is callable from a
#[tokio::test] - handy when you want a known fixture set in front of an
integration test:
use serial;
use TestContainer;
use ;
use BaseSeeder;
async
Two notes on the test shape:
#[serial]is required when the test mutates the process-global registry - parallel tests sharing the same registry will race. Addserial_testas a dev-dependency in your project'sCargo.tomlto get the attribute.seed::clear()is a#[doc(hidden)]test-only helper. Don't call it from production code; the registry is built once at boot and never reset.
See Testing for the broader test-harness conventions
(#[suprnova_test], TestContainer, TestDatabase::fresh::<Migrator>(),
the fakes for every external surface).
When to seed, migrate, or factory
These three patterns all put rows into tables. The decision is usually straightforward, but it's worth naming the dividing lines explicitly because PHP teams often blur them.
| You want… | Use |
|---|---|
| A column to exist | Migration |
| A row that must exist for the app to boot (the default admin, the singleton site-config row, the canonical list of currencies) | Seeder - idempotent, runs in every environment, including production |
| A randomized set of rows for local dev or staging (50 users, 200 posts, 1000 events) | Seeder that calls a factory |
| A row a unit test needs | Factory called directly inside the test |
| The shape of a row | Factory |
The mistakes to avoid:
- Don't insert data from a migration. Migrations describe schema, not
state. A migration that inserts a default row will run once on the
production database and then never again - the moment a column changes, you
have a forked source of truth between migration history and the seeder.
Put the insert in a seeder; if production needs the row, run
console db:seed --class=DefaultsSeederas part of deploy. - Don't write fixture data into your test by hand. Reach for a factory.
Five
User::create(attrs!{ … })blocks in a test are five rewrites the moment you add a NOT NULL column. OneUserFactory::new().create()survives. - Don't put production data in a seeder. A seeder is for the rows the
application requires to function, not for "here are the 8,000 historical
records we're importing." Imports are one-off scripts (write a
#[command]for them; see Console).
Why Suprnova diverges
Laravel ships a DatabaseSeeder class with a special-case call($seeders)
helper that Eloquent's seeder loader recognises. Suprnova doesn't - the
registry is a flat IndexMap, every seeder is a peer, and a composite
seeder calls seed::run_one(name) (or just calls the sub-factories directly)
to chain.
The reason is the same trade-off you see elsewhere in Suprnova: a single
generic registry with one ordering rule is easier to reason about than a
class hierarchy with a magic root. The Laravel pattern works because PHP's
class autoloading and the static make() reflection let call([A::class, B::class]) find and instantiate those classes by name; in Rust we'd be
asking the user to thread dyn Seeder trait objects around, which is
clunkier than the function-pointer registry that's already there.
The composite-seeder convention recovers the same ergonomics - BaseSeeder
plays the role DatabaseSeeder plays in Laravel - without needing the
framework to bless one name as special.
Bootstrap registration
Every seeder needs a seed::register call in bootstrap.rs, alongside the
other process-global wiring (config, observers, supervisors, queue jobs).
The pattern is the same shape used elsewhere in the bootstrap file:
// src/bootstrap.rs
pub async
If you forget to register a seeder, console db:seed --class=X fails with
"no seeder registered for X" - a clear signal rather than a silent skip.
The seed::count() and seed::is_registered("…") helpers exist precisely
so a test can assert the bootstrap registered every seeder you expected.
See Bootstrap for the full file's structure and the order the framework expects each subsystem to be wired in.
Next
- Migrations - the schema half of the seed/migrate pair
- Eloquent - models, factories, and the
Persistablemachinery every seeder calls into - Console - the per-project
consolebinary that hostsdb:seedalongside your own#[command]s - Testing -
TestContainer,TestDatabase::fresh, and the#[serial]pattern for tests that touch the seeder registry - Error Model - what
FrameworkErroris and howrun'sResult<(), _>shape composes with the rest of the framework
