Suprnova ships about three dozen macros, every one of them re-exported
from suprnova::*. They're the joints where the framework meets your
code - routes! builds the router, #[handler] adapts a function
into one, #[suprnova::model] turns a struct into an Eloquent model,
#[derive(Data)] produces a typed Inertia payload. This chapter is
the index. Each macro gets a one-paragraph description, a minimal
example, and a pointer to the chapter that uses it for real work.
A few principles that hold across the whole surface:
- Macros emit fully-qualified paths. Generated code writes
::suprnova::…so the macros work whether or not you've imported the underlying types. - Heavy use of
inventory::submit!. Models, commands, policies, observers, payment providers, and more register themselves at compile time and the framework drains the registry at boot. You almost never wire registration by hand. - Compile-time validation where it pays.
inertia_response!checks that the named component file exists.redirect!checks that the named route exists.routes!rejects paths that don't start with/. Errors that can be caught at build time are.
Routing
| Macro | Returns | What it does |
|---|---|---|
routes! |
pub fn register() -> Router |
Top-level list of routes - exports a register() your app.rs calls |
get! / post! / put! / delete! / patch! / head! / options! / any! |
RouteDefBuilder<H> |
One HTTP route - chainable .name(...) / .middleware(...) |
group! |
GroupDef |
Prefix + middleware applied to a child list of routes |
fallback! |
FallbackDefBuilder<H> |
Custom 404 handler when no route matches |
ws! |
WsRouteDef |
One WebSocket route - chainable .middleware(...) / .config(...) |
use ;
use crate::;
routes!
The route-path string is checked at compile time - validate_route_path
rejects anything that doesn't start with /. Route names registered
via .name("…") are also checked for uniqueness at boot through
register_route_name. See Routing for the full
expansion and WebSockets for ws!.
Handlers and requests
#[handler]
Rewrites a controller function so it can extract typed parameters
(via FromRequest) directly from the incoming request - instead of
manually pulling fields off Request, you declare what the handler
needs and the macro wires it up.
use ;
pub async
A Request-shaped first parameter is still accepted as the
identity case. See Controllers.
#[request] and #[derive(FormRequest)]
#[request] is the recommended way to declare a validated request
type. It auto-derives Deserialize, Validate, and FormRequest,
so the struct works with both application/json and
application/x-www-form-urlencoded bodies.
#[derive(FormRequestDerive)] is the underlying derive if you want
to opt out of the attribute (you'll need to derive Deserialize and
Validate yourself). The attribute is what we recommend; the derive
exists for the edge case. See Requests and
Validation.
#[derive(MultipartRequest)]
Strongly-typed extractor for multipart/form-data - bind text fields
and uploaded files in one struct, with per-field type-level validators.
use ;
use ;
Built-in validators (Image, MimeAllowlist<…>, MaxSize<…>,
MimeType<…>) compose via tuples. See Requests.
Responses
json_response! and text_response!
The two short-form response macros. Both wrap HttpResponse::* in
Ok(...) so they slot straight into a handler's return position:
use ;
pub async
pub async
See Responses.
inertia_response!
Builds an Inertia page response, validating at compile time that the
named component file (.svelte / .tsx / .jsx / .vue) exists in
frontend/src/pages/. If you misspell the component name, the build
fails with suggestions:
use ;
pub async
#[derive(InertiaProps)] generates the Serialize impl the response
shape needs. See Inertia Responses.
redirect!
Type-safe redirect to a named route - the route name is verified at
compile time against the names registered through routes!:
use redirect;
// Compiles only if "users.show" is a registered route name
let resp = redirect!.with.into;
See URL Generation.
Eloquent
#[suprnova::model]
Turns a plain struct into a full Eloquent model: generates SeaORM
Entity, Model, ActiveModel, Column, Relation stubs, plus
all the trait impls Eloquent needs. Also inventory::submit!s a
ModelEntry so the framework can enumerate every model at boot.
use model;
Attribute keys include table, primary_key, key_type,
auto_increment, connection, fillable, guarded, casts,
timestamps, soft_deletes, appends, hidden, visible,
mutators, touches, and unique_id (for UUID/ULID PKs). See
Eloquent.
#[suprnova::scopes(Model)]
Walks an impl Model { … } block and turns every method whose
signature matches fn name(query: Builder<Self>[, args…]) -> Builder<Self>
into a scope - generating both Model::scope_name(args) and a
chainable .scope_name(args) on Builder<Model>.
use ;
// Both call sites compile:
// User::active().popular(500).get().await?;
// User::query().filter_op("id", ">", 0).active().get().await?;
The chainable form requires the generated trait
HasScope_<scope>_<Model> in scope when called from a different
module. See Eloquent.
#[suprnova::observer(Model)]
Wires an impl Observer<M> block into the lifecycle-event system -
each of the 16 overridden methods becomes a registered listener,
submitted to inventory and drained at boot.
use async_trait;
use Observer;
use EventResult;
use Attrs;
use FrameworkError;
;
Required attribute ordering: #[suprnova::observer(M)] must come
before #[async_trait]. Attribute macros expand outside-in - if
async_trait runs first, it rewrites every async fn into a
desugared shape and the observer macro's name-match against the 16
trait method names silently finds nothing. See Events.
#[suprnova::accessor] and #[suprnova::mutator]
Function-level markers on impl Model { … } methods that hook into
the model's to_json() / fill() paths. Reference the field name
in #[model(appends = […])] (accessor) or #[model(mutators = […])]
(mutator) for the macro to wire them up.
See Mutators & Casts.
#[suprnova::prunable]
Wraps a Prunable (or MassPrunable) impl and submits a PrunerEntry
into the registry that model:prune walks at runtime:
use async_trait;
use ;
use Prunable;
See Eloquent.
attrs!
Builds an ordered Attrs map (IndexMap<&'static str, serde_json::Value>)
for Model::create / Model::update / Model::fill:
use attrs;
let user = create.await?;
See Eloquent.
casts!
Builds a per-query cast map you can pass to Builder::with_casts:
use ;
let map = casts! ;
let rows = query.with_casts.get.await?;
See Mutators & Casts.
route_binding!
Implements RouteBinding for a hand-rolled SeaORM entity so it
resolves automatically from a route parameter. Models defined with
#[suprnova::model] register automatically and don't need this; reach
for route_binding! when you wrote the entity by hand:
use route_binding;
route_binding!;
After that, get!("/users/{user}", controllers::user::show) passes
a fully-loaded User to your handler. See Routing.
Data and Inertia
#[derive(Data)]
The composite derive for typed payloads. Produces a Serialize impl
that respects #[data(input_only)] fields, plus a Deserialize impl
that rejects payloads attempting to set #[data(output_only)] fields.
Pair with #[json_resource("type")] for JSON:API output via the
Resource chapter.
use ;
#[data(allow_include)] registers the field in the partial-reload
include allowlist via inventory::submit!. See
Data Objects and API Resources.
#[derive(InertiaProps)]
Generates the Serialize impl inertia_response! needs. Plain marker
derive - most apps reach for #[derive(Data)] instead because it gives
you partial-reload includes for free.
use InertiaProps;
See Inertia Responses.
when_loaded!
Emits a Prop::lazy(…) only when a named relation has been
eager-loaded on the entity; otherwise emits Prop::EagerNone so the
prop is skipped from the response entirely:
use when_loaded;
let songs_prop = when_loaded!;
See Data Objects.
Dependency injection
#[service]
Adds Send + Sync + 'static to a trait so it slots into the container:
use service;
// App::bind::<dyn HttpClient>(Arc::new(RealHttpClient::new()));
// let client = App::make::<dyn HttpClient>()?;
See Service Container.
#[injectable]
Auto-registers a concrete type as a singleton. Derives Default +
Clone and submits a registration that runs at boot:
use injectable;
// let state: AppState = App::get().unwrap();
See Service Container.
Errors
#[domain_error]
Defines a domain error that implements Display, Error, HttpError,
and From<T> for FrameworkError - so it short-circuits a handler via
?:
use domain_error;
pub async
See Error Handling.
Console and background work
#[command]
Marks an async fn(Vec<String>) -> Result<(), FrameworkError> as a
console command. Submits a CommandEntry so dispatch_argv finds it
when the per-project console binary runs:
use ;
async
See Console.
#[derive(Command)]
The typed-args alternative. Goes on top of #[derive(clap::Parser)],
reads #[console(...)] for metadata, and emits the runner that calls
your TypedCommand::run:
use async_trait;
use ;
See Console.
#[workflow] and #[workflow_step]
#[workflow] registers an async fn as a durable workflow - runnable
state, retriable steps, persisted history. Each #[workflow_step]
inside the body is a checkpoint the runtime can resume from after a
crash or restart.
use ;
async
async
start_workflow!
Kicks off a workflow by path, serialising the args into the workflow runtime's envelope shape:
use start_workflow;
let handle = start_workflow!.await?;
See Workflows.
schedule_task!
Sugar around TaskBuilder::from_async so a closure schedules cleanly
alongside trait-based Task impls:
use ;
let task = schedule_task!
.every_minute
.name;
See Task Scheduling.
Authorization
#[policy(UserType, ResourceType)]
Wraps an impl Policy block and registers each method as a named
gate action. The gate name combines the method name with the
lowercased resource type - fn view(...) on Comment becomes
"view-comment":
use policy;
;
Server::run calls authorization::init_policies() automatically.
See Authorization.
Notifications and mail
#[derive(NotificationMailable)]
Auto-generates to_mail from a #[mail(...)] attribute - inline or
file-backed Tera templates for subject, HTML body, and text body.
Compile-time checks: subject required, at least one body present,
exclusive html/html_template, from_name requires from:
use ;
use NotificationMailable;
The notification trait itself is hand-implemented - there is no
#[derive(Notification)]. See Notifications and
Mail.
Validation
validate!
Sync, declarative validation entry point. Each row pairs a field name
with one or more Rule (or ContextualRule) values, with ?: for
"present-only validate" and ?=> for conditionally-required optional
fields:
use ;
use *;
Validate is re-exported from the validator crate - #[validate(...)]
attributes (e.g. #[validate(email)]) come from validator and run
through FormRequest's sync path. Use validate! when you need
contextual / cross-field rules, async rules, or rules from the
suprnova::validation::rules palette. See Validation.
Factories
#[derive(Factory)]
Generates a sibling <Model>Factory marker and a Factory impl that
produces models via fake::Faker. The model must implement
fake::Dummy<fake::Faker> - typically via #[derive(Dummy)]:
use ;
// UserFactory exists:
let users = new.count.make_many;
See Factories.
Testing
#[suprnova_test]
Wraps an async fn test with an in-memory SQLite database (running
crate::migrations::Migrator by default), invokes App::init() and
App::boot_services(), and runs the body under #[tokio::test].
Parallel tests stay hermetic through the container's per-thread
layer - bind test-specific services through TestContainer::fake
(not App::bind) so each thread sees its own fakes:
use suprnova_test;
use TestDatabase;
async
A custom migrator goes via #[suprnova_test(migrator = MyMigrator)].
See Testing.
test_database!
The one-line TestDatabase constructor for tests that don't take the
db parameter through #[suprnova_test]:
let db = test_database!;
let db = test_database!;
describe!, test!, expect!
Jest-style grouping + fluent assertions. describe! is a module,
test! produces a #[test] (sync or async, with or without a
TestDatabase parameter), and expect! wraps a value for chained
assertions with file/line context on failure:
use ;
describe!;
See Testing.
Middleware
global_middleware!
Registers a middleware that runs on every request, in registration order, before any route-specific middleware. Idempotent per type:
use global_middleware;
use cratemiddleware;
Must run before Server::from_config / Server::new - the server
snapshots the global registry at build time. See
Middleware.
Pitfalls
A short list of failure modes that are easy to hit and easy to fix.
Attribute ordering - #[observer] must come before #[async_trait]
// CORRECT
// WRONG - silently emits zero listeners
Attribute macros expand outside-in. async_trait rewrites every
async fn into a desugared Pin<Box<dyn Future>> shape. If it runs
first, the observer macro can no longer match by method name and
emits nothing. The same outside-in rule applies whenever you stack
multiple macros - put the Suprnova attribute outermost when in doubt.
The inherent-impl trap
An inherent impl method cannot shadow a trait's default method
through trait dispatch. If you write a macro (or hand-write code)
that defines fn save(&self) on a model as an inherent method,
calls that go through the Model trait (some_model.save() where
the call site only knows it as &dyn Model) will pick the trait
default - not your inherent override.
Fix: emit a trait-method override, never an inherent method, when the
generated behaviour must participate in trait dispatch. This is why
the framework's macros (notably #[suprnova::model]) write to the
trait impl. If you're hand-rolling Eloquent extensions, do the same.
global_middleware! only takes effect before Server::from_config
The server snapshots the global registry when it's built. Calling
global_middleware!(M) after Server::from_config(...) does not
retroactively apply to that server. Register every global middleware
in bootstrap(), before Application::run() reaches the serve step.
redirect! and inertia_response! are build-time checks
Both macros refuse to compile if the named target doesn't exist -
that's the point. If a refactor removes a route or component name,
every call site that mentions it breaks the build, which is exactly
what you want. If the build error surprises you, search for the
string literal in your routes! block / pages directory before
"fixing" the macro call.
?: skips on None; ?=> runs even on None
In validate! rows, ?: only runs rules when the field is Some.
A presence-conditional rule like RequiredIf on a ?: row therefore
can never fail an absent field. Use ?=> (which treats absence as
"") for the require-when-X case.
#[derive(Validate)] is from the validator crate, not Suprnova
Suprnova re-exports validator::Validate so you don't take a direct
dep on validator. The #[validate(...)] attributes come from
validator. Suprnova's own validate! macro is the runtime
cross-field / contextual entry point; the two complement each other
but live in different namespaces.
Why Suprnova diverges
Laravel discovers routes, commands, mail templates, model classes, factories, observers, and policies at runtime - through reflection, filesystem scanning, and string-based dispatch. PHP makes that cheap (autoloading + opcache amortise the cost), and the developer experience is excellent: drop a file in the right directory and it shows up.
That model doesn't fit Rust. We don't have runtime reflection on trait impls, runtime is a single statically-linked binary, and filesystem scans at boot are a worse fit for a process model where each binary serves millions of requests.
So Suprnova does the same job at compile time. Routes are validated,
component names are checked against the pages directory, mail
templates are embedded via include_str!, route names are checked
for uniqueness through inventory, models register themselves in an
inventory the framework drains at boot, commands the same. The
developer experience is similar - drop a file, add a #[command]
or #[suprnova::model], run the binary - but the wiring happens
before main instead of at the first request.
The trade is that misspellings, missing components, and broken references are build errors instead of runtime errors, and there's zero per-request reflection cost.
Next
- Routing - full
routes!expansion, naming, model binding - Controllers -
#[handler]and#[request]together - Eloquent -
#[suprnova::model]and friends in context - Validation -
validate!, contextual rules, async rules - Console -
#[command]and#[derive(Command)]end to end - Testing -
#[suprnova_test],expect!, fakes
