Manual contentsGetting StartedBrowse 103 chapters
Manual 14 min read

From Laravel

If you've shipped Laravel apps, you already know 80% of Suprnova. This chapter maps your habits to the Rust equivalent so you can get productive fast. We'll show the patterns you reach for daily, the patterns that change shape, and the few things Rust gives you for free that PHP can't.

TL;DR side-by-side

You wrote in Laravel You write in Suprnova
composer create laravel/laravel my-app suprnova new my-app --frontend svelte
php artisan serve suprnova serve
php artisan migrate suprnova migrate
php artisan make:controller PostController suprnova make:controller post
Route::get('/posts/{id}', [PostController::class, 'show']) get!("/posts/{id}", controllers::post::show) (in routes!)
class Post extends Model #[suprnova::model] struct Post { … }
Post::find($id) Post::find(id).await?
Post::where('status', 'published')->get() Post::query().db_where("status", "published").get().await?
Auth::user() Auth::user().await?
Cache::remember('key', 60, fn() => …) Cache::remember("key", Some(Duration::from_secs(60)), || async { … }).await?
Queue::push(new SendEmail($user)) Queue::push(SendEmail { user_id }).await?
Mail::to($u)->send(new Welcome($u)) Mail::to(&u.email).send(WelcomeMail { user: u }).await?
Storage::disk('s3')->put($path, $bytes) Storage::disk("s3")?.put(&path, bytes).await?
Notification::send($u, new Invoice($i)) Notify::send(&u, &InvoiceNotification { invoice }).await?
Gate::allows('update', $post) Gate::allows::<PostPolicy, _>("update", &user, &post).await?
request()->validate([...]) #[handler] extracts an #[derive(Data, Validate)] arg directly
event(new OrderShipped($order)) EventFacade::dispatch(OrderShipped { order }).await?
Bus::dispatch(new ProcessFoo($x)) Bus::dispatch(ProcessFoo { x }).await?
php artisan schedule:list suprnova schedule:list
php artisan tinker (no REPL - write a one-off cargo run script or test)
composer require league/csv cargo add csv

The mental model shift

Async, everywhere

The biggest change: every database call, HTTP call, file I/O, cache call, queue push - anything that crosses a boundary - is async and you call it with .await?. Once you've done it for a few hours, it disappears into the rhythm. Until then, the compiler will point at every spot you forgot.

// Laravel
$user = User::find($id);
$user->subscribe($plan);
Mail::to($user)->send(new Welcome($user));

// Suprnova
let user = User::find(id).await?;
user.subscribe(&plan).await?;
Mail::to(&user.email).send(WelcomeMail { user }).await?;

? is Rust's "early return on error". A handler returns Result<HttpResponse, HttpResponse> (aliased as Response), so a ? on a DB error short-circuits into your error converter and the client gets a proper 500 (or 4xx, depending on the error kind). You almost never have to write a try/catch - ? does it.

Compile-time models

Where Eloquent reads your DB schema at runtime, Suprnova reads it at compile time:

#[suprnova::model(table = "posts")]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub body: String,
    pub published_at: Option<chrono::DateTime<chrono::Utc>>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

That's it - that struct IS the Eloquent model. You get Post::find, Post::query(), Post::create, post.update(...), post.delete(), soft deletes (with #[model(soft_deletes)]), timestamps, observers, the works. The macro generates a SeaORM Entity, Model, ActiveModel, and Column enum, and impls the Suprnova Model trait - but you depend on Post, not any of those.

If you rename a column in a migration, the struct doesn't match the DB schema anymore - and depending on your config, either the compiler catches it at build time or the type-coerced cast fails on first query. Either way you find out before staging, not after.

Single binary

There's no PHP-FPM, no nginx config reading index.php, no composer install on deploy. cargo build --release gives you one statically linked binary. scp it to a server, systemd it, done. Or build a container - FROM scratch works.

We have deployment recipes for Railway, Digital Ocean, and Hetzner. The common shape: build the binary, ship the binary, set env vars, run.

Mapping the framework

Routes

routes! plays the role of routes/web.php and routes/api.php combined.

use suprnova::{routes, get, post, put, delete};
use crate::controllers;

routes! {
    get!("/", controllers::home::index).name("home"),

    // Route group with shared prefix + middleware
    group("/admin")
        .middleware(crate::middleware::admin())
        .routes(routes! {
            get!("/users", controllers::admin::users::index).name("admin.users"),
            post!("/users", controllers::admin::users::store),
            put!("/users/{id}", controllers::admin::users::update),
            delete!("/users/{id}", controllers::admin::users::destroy),
        }),

    // Resource routing (Laravel's Route::resource)
    resource!("posts", controllers::post),
}

Full reference: Routing. Differences worth knowing:

  • Group middleware is flattened into each route's middleware list at register time (not run as a separate chain layer) - this means there's no extra runtime cost for grouping.
  • Both Laravel's {id} and Rails-style :id syntax work; they're normalised internally.
  • Named routes resolve via route("posts.show", &[("id", "42")]) and there's a signed-URL variant for time-limited links.

Controllers

A controller is just a free function returning Response:

use suprnova::{Request, Response, json_response, HttpResponse};
use crate::models::Post;

pub async fn show(req: Request) -> Response {
    let id = req.param("id").unwrap_or("0").parse::<i64>()?;
    let post = Post::find_or_fail(id).await?;
    json_response!({ "post": post })
}

You can also use the #[handler] macro to extract typed args (route params, query, body, the request itself, container services) at the signature:

use suprnova::handler;

#[handler]
pub async fn show(post: post::Model) -> Response {
    // Route model binding ran automatically; `post` is the loaded row.
    json_response!({ "post": post })
}

The post::Model type comes from the model's generated module - that's the signal #[handler] uses to pick route model binding over the default form-request extraction. If the row doesn't exist, the binding returns a 404 before your code runs - same behaviour as Laravel's implicit binding.

Action structs (single-method "invokable" controllers, Laravel-style) are supported too: see Actions.

Eloquent

The dual-API query builder takes either Laravel names or Rust-idiomatic names - both work, pick whichever reads cleanly at the call site.

// Laravel surface
let active = User::query()
    .db_where("status", "active")
    .order_by_desc("created_at")
    .limit(20)
    .get()
    .await?;

// Rust surface (identical result)
let active = User::query()
    .filter("status", "active")
    .order_by_desc("created_at")
    .take(20)
    .get()
    .await?;

db_where is the Laravel-side name (the bare where collides with the Rust keyword). filter is the Rust-idiomatic alias. Both exist; both do the same thing. For non-equality operators, reach for db_where_op (or its filter_op alias): .db_where_op("status", "!=", "archived"). See the Eloquent reference - it's the longest chapter for a reason, the surface is wide.

Auth

use suprnova::{Auth, Credentials};

// In a handler:
let user = Auth::user().await?;   // Option<Arc<dyn Authenticatable>>
let id = user.as_ref().map(|u| u.get_auth_identifier());

// Logging in (e.g. inside your login controller):
let creds = Credentials::password("alice@x.com", "secret");
Auth::attempt(&creds, false).await?;

// Logging out:
Auth::logout().await?;

Guards, providers, sessions, remember-me, email verification, password reset, brute-force throttling, TOTP 2FA, and OAuth are all here. The auth-flows surface mirrors Laravel Fortify. Email verification and password reset are provider-backed (no torii required): your user model implements MustVerifyEmail / CanResetPassword - the Suprnova analogues of Laravel's contracts of the same names - and the configured UserProvider drives the flows. See Authentication and Auth Flows.

Migrations

You write SeaORM migrators. The shape will look familiar even if the syntax is new:

use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager.create_table(
            Table::create()
                .table(Alias::new("posts"))
                .if_not_exists()
                .col(ColumnDef::new(Alias::new("id")).big_integer().primary_key().auto_increment())
                .col(ColumnDef::new(Alias::new("title")).string().not_null())
                .col(ColumnDef::new(Alias::new("body")).text().not_null())
                .to_owned()
        ).await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager.drop_table(Table::drop().table(Alias::new("posts")).to_owned()).await
    }
}

suprnova make:migration create_posts_table scaffolds the file. suprnova migrate, migrate:rollback, migrate:status, migrate:fresh all do what you'd expect. suprnova db:sync runs migrations and regenerates the SeaORM entities the macro layer compiles against. See Migrations.

Queues and scheduling

use suprnova::{FrameworkError, Job, Queue, async_trait};
use serde::{Deserialize, Serialize};

// Define a job - the data lives on the struct, the contract lives on
// `impl Job`.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SendWelcomeEmail {
    pub user_id: i64,
}

#[async_trait]
impl Job for SendWelcomeEmail {
    fn job_name() -> &'static str {
        "SendWelcomeEmail"
    }

    async fn handle(self) -> Result<(), FrameworkError> {
        let user = User::find_or_fail(self.user_id).await?;
        Mail::to(&user.email).send(WelcomeMail { user }).await?;
        Ok(())
    }
}

// Push it onto the queue:
Queue::push(SendWelcomeEmail { user_id: user.id }).await?;

// Or with a delay:
Queue::later(
    std::time::Duration::from_secs(60),
    SendWelcomeEmail { user_id },
).await?;

Workers run with cargo run -- queue:work. Drivers include memory and sync (in-process, for tests), database, redis, and null. Batches, chains, unique jobs, retries, backoff, middleware, failed-job store - all there. See Queues.

Scheduling uses the Task trait and the per-project scheduler binary:

use suprnova::{Task, TaskResult, async_trait};

pub struct DailyDigest;

#[async_trait]
impl Task for DailyDigest {
    async fn handle(&self) -> TaskResult {
        //        Ok(())
    }
}

// Register inside bootstrap (e.g. via Schedule::call / .task / .add):
//   schedule.add(schedule.task(DailyDigest).daily().at("03:00").name("daily-digest"));

See Task Scheduling.

Mail, notifications, broadcasting

These follow Laravel one-to-one. Mailable is a derive macro; Notifiable is a trait on your User model; channels are mail/database/broadcast/webpush; broadcasting supports public, private, and presence channels. See Mail, Notifications, Broadcasting.

Frontend

There's no Blade. Instead, the frontend is a real SPA via Inertia.js, and you pass typed props from Rust:

use suprnova::{inertia_response, InertiaProps, Request, Response};

#[derive(InertiaProps, serde::Serialize)]
pub struct ShowProps {
    pub post: Post,
    pub comments: Vec<Comment>,
}

pub async fn show(req: Request) -> Response {
    let id: i64 = req.param("id").unwrap_or("0").parse().unwrap_or(0);
    let post = Post::find_or_fail(id).await?;
    let comments = post.comments().get().await?;
    inertia_response!(&req, "Posts/Show", ShowProps { post, comments })
}

Posts/Show is a Svelte component (or React, or Vue - your starter picks). TypeScript types for the props are generated automatically from the InertiaProps derive - run suprnova generate-types after adding a new prop struct and the frontend gets typed bindings.

If you've used Inertia in Laravel via inertia(), this is the same thing - just typed end-to-end. See the Frontend overview.

Things that change shape

A few things move differently in Suprnova. None of them are blockers, but they're worth knowing up front.

No service providers

Laravel has dozens of service providers registering bindings, observers, view composers, etc. Suprnova has one bootstrap function in your app's bootstrap.rs. You register everything there, in order. It's not elegant but it's transparent - you can see in 30 lines exactly what your app boots.

// bootstrap.rs
use std::sync::Arc;

pub async fn register() {
    suprnova::App::bind::<dyn MyService>(Arc::new(MyServiceImpl::new()));
    suprnova::Event::listen::<OrderShipped, _>(Arc::new(SendShipmentNotification)).await;
    crate::observers::register();
}

The Container and Bootstrap chapters have the detail.

Configuration is typed

Where Laravel uses config('app.timezone') returning whatever-the-array-says, Suprnova has typed config structs:

let cfg = suprnova::Config::get::<AppConfig>()?;
let tz = &cfg.timezone;   // &str, not mixed

You can register your own typed config sections. See Configuration.

No facades-as-aliases

Laravel facades like DB:: are class-aliases configured in config/app.php. Suprnova facades are real modules at the crate root:

use suprnova::{Auth, Cache, DB, Event, Gate, Mail, Notify, Queue, Schedule, Storage};

Same surface, no global aliasing needed.

Compile times are real

Rust compile times are not PHP. A clean build of a fresh Suprnova app takes 1–2 minutes; incremental builds during development are a few seconds. The dev workflow is the same - suprnova serve watches for changes and rebuilds - but you'll feel it the first time you change a macro and recompile a downstream crate. Caching pays for itself fast.

The borrow checker exists

Most controllers and handlers never touch a lifetime annotation - the framework's signatures hide them. When the borrow checker yells at you, it's usually because you tried to hold a reference across an .await that crossed a mutex or held a DB transaction across an awaited call that needed exclusive access. The errors are clear and the fixes are usually .clone() or restructure-into-smaller-scopes.

No tinker REPL

There isn't a REPL. The closest equivalent is a one-off cargo run script in examples/, or a #[suprnova_test] test that exercises the thing you're debugging. Most of what you'd do in tinker (poke at a model, fire a notification, dispatch a job) is a 5-line test.

Where Laravel chapters land

Quick lookup if you know what you're after but not where it lives:

Laravel topic Suprnova chapter
Lifecycle Request Lifecycle
Service Container Service Container
Service Providers Application Bootstrap
Facades Service Container
Routing Routing
Middleware Middleware
CSRF Protection CSRF Protection
Controllers Controllers
Requests Requests
Responses Responses
URL Generation URL Generation
Session Session
Validation Validation
Error Handling Error Handling
Logging Logging
Artisan Console Console + CLI Reference
Broadcasting Broadcasting
Cache Cache
Events Events
File Storage File Storage
HTTP Client HTTP Client
Localization Localization - Fluent .ftl catalogs, not PHP arrays
Mail Mail
Notifications Notifications
Queues Queues
Rate Limiting Rate Limiting
Task Scheduling Task Scheduling
Authentication Authentication
Authorization Authorization
Email Verification Auth Flows
Password Reset Auth Flows
Encryption Encryption
Hashing Hashing
Database Database
Query Builder Query Builder
Pagination Pagination
Migrations Migrations
Seeding Seeding
Eloquent Eloquent
Eloquent: Relationships Relationships
Eloquent: Collections Collections
Eloquent: Mutators / Casts Mutators & Casts
Eloquent: API Resources API Resources
Eloquent: Serialization Serialization
Eloquent: Factories Factories
Testing Testing
HTTP Tests HTTP Tests
Database Testing Database Tests
Mocking Mocking & Fakes
Cashier (Stripe) Payments: Stripe
Cashier (Paddle) Payments: Paddle
Sanctum / Passport (not yet - token auth via torii integration)
Horizon (not yet - queue introspection is built-in)
Telescope / Pulse (deferred to v2+)

Things Laravel has that Suprnova doesn't (yet):

  • Telescope / Pulse (observability surface) - basic observability ships, the dashboards don't
  • Sanctum / Passport token auth - torii integration covers OAuth and session auth; dedicated token auth is intended, not shipped
  • Horizon - queue introspection is built into the framework, no separate dashboard
  • Blade - by design; Inertia is the frontend story
  • trans_choice - Localization ships, but plurals are selected inside the message by CLDR category rather than by the [1,19]-style integer ranges trans_choice takes

Next

Or jump anywhere via documentation.md.