Rustlavel

Cookbook

Short answers to the things people need in the middle of an afternoon.

The guide reads front to back. This does not: each recipe stands alone, says which feature flag it needs, and shows the whole thing rather than a fragment. Every snippet is written against the version on the front page.

Forms

Validate a form and redraw it when it fails

Propagate the error and the framework does the rest — a browser is redirected back to the form with the messages and the old input, and a JSON client gets a 422 in Laravel’s shape. One handler serves both.

pub async fn store(mut req: Request) -> Result<Response, Errors> {
    let data = validate(&mut req, &[
        ("title", "required|max:120"),
        ("body",  "required|min:10"),
    ]).await?;

    let db = req.state::<Database>().expect("registered in main.rs");
    let mut post = Post {
        title: data.string("title").unwrap_or_default(),
        body:  data.string("body").unwrap_or_default(),
        ..Default::default()
    };
    post.insert(db).await?;

    Ok(Response::see_other(format!("/posts/{}", post.id)))
}

The form reads what the failed request left behind:

pub async fn create(req: Request) -> Result<Response> {
    req.view("posts/create", &ViewContext::new()
        .with("errors", req.errors())
        .with("old", req.old()))
}
<form method="post" action="/posts">
  {!! csrf_field !!}
  @if(errors.title)<p class="error">{{ errors.title.0 }}</p>@endif
  <input name="title" value="{{ old.title }}">
  <button>Publish</button>
</form>

The old input never contains a password, or anything else whose field name looks like a secret. Re-filling a password box means putting the password back into HTML, and that HTML ends up in caches, in browser history and in screenshots.

Validate something that is not a form field

validate reads the request body. When the values are already in hand — normalised, or assembled from several places — build the validator directly:

use rustlavel::validation::Validator;

let input = Json::object([("email", Json::from(email.trim().to_lowercase()))]);
let errors = Validator::new(&input)
    .rules(&[("email", "required|email|max:190")])
    .errors();

if !errors.is_empty() { … }

Files

Accept an upload and serve it back

Needs storage.

pub async fn avatar(mut req: Request) -> Result<Response> {
    let storage = req.state::<Storage>().expect("registered in main.rs");

    let Some(file) = req.file("avatar") else {
        return Ok(Response::new(Status::BAD_REQUEST).with_text("No file was sent."));
    };
    // Never trust the filename the browser sent: it can contain a path.
    let name = format!("avatars/{}.png", rustlavel::auth::random::hex(16));
    storage.put(&name, file.bytes()).await?;

    Ok(Response::see_other("/profile"))
}

Uploads belong under storage/app, outside the web root. To make a subset of them reachable over HTTP, write them under storage/app/public and link it once:

rustlavel storage:link

Set a per-route upload limit

The server has a body limit that protects the process, and it has to be as large as the biggest upload the application accepts anywhere. BodyLimit is the policy on top:

r.group("/api", |api| {
    api.middleware(BodyLimit::kilobytes(64));   // JSON endpoints
    …
});
r.post("/avatar", upload).middleware(BodyLimit::megabytes(5));

Data

Paginate a listing

Needs db.

let page = req.query("page").and_then(|p| p.parse().ok()).unwrap_or(1);
let listed = Post::query().latest("id").paginate(db, page, 20).await?;

req.view("posts/index", &ViewContext::new()
    .with("posts", listed.to_json()))

Past a few hundred thousand rows the count query starts to hurt. Cursor pagination has no count and stays correct while rows are being inserted underneath the reader:

let page = Post::query()
    .cursor_paginate(db, req.query("cursor"), 20)
    .await?;

Return JSON with one shape per type

pub struct PostResource;

impl JsonResource for PostResource {
    type Model = Post;

    fn to_json(post: &Post) -> Json {
        attributes()
            .set("id", post.id)
            .set("title", post.title.as_str())
            .when(post.published, "published_at", || Json::from(post.published_at.clone()))
            .finish()
    }
}

// In a controller:
PostResource::collection(&listed.hydrate::<Post>()?)
    .paginate(listed.current_page, listed.per_page, listed.total)
    .path("/api/posts")

A when() that is false leaves the key absent, not null. A client told "published_at": null has to work out what null means; a client not told about it knows it is not published.

Run several statements as one

let mut tx = db.begin().await?;
tx.execute("update accounts set balance = balance - ? where id = ?", &[amount.into(), from.into()]).await?;
tx.execute("update accounts set balance = balance + ? where id = ?", &[amount.into(), to.into()]).await?;
tx.commit().await?;

Dropping the transaction without committing rolls it back, so an early ? leaves nothing half-applied.

Sell credits that cannot be spent twice

A balance that has to be right now, under load, not after a nightly job. rustlavel-ledger is double entry — every movement is a transfer between two accounts writing two equal and opposite entries — so the sum of every balance is zero and audit() can say whether it still is. Enable the ledger feature and create the tables with ledger::create_tables, or register CreateLedgerTables in your migrations.

use rustlavel::ledger::Ledger;
use std::time::Duration;

async fn credits(db: Database) -> rustlavel::Result<()> {
    let ledger = Ledger::new(db, "credits");

    // A customer pays. The gateway may deliver the webhook three times.
    ledger.top_up("user:42", 500, "payment_abc", Some(Duration::from_secs(60 * 86_400))).await?;
    ledger.top_up("user:42", 500, "payment_abc", None).await?; // same reference: moves nothing

    // A job that costs 50 and takes a while: reserve it, run it, settle it.
    let hold = ledger.hold("user:42", 50, "render_7", Duration::from_secs(900)).await?;
    // ... the job runs ...
    ledger.capture(&hold.id).await?; // or `ledger.release(&hold.id)` if it failed

    let balance = ledger.balance("user:42").await?;
    println!("{} available of {}", balance.available, balance.total);
    Ok(())
}

Top-ups and consumes are idempotent on their reference: the second top_up above moves nothing, which is what makes a retried payment webhook safe. A hold reserves credits for a job that will take a while — available drops, total does not — and is captured or released exactly once. Credits can expire by batch, oldest spent first, and sweep() releases the holds nobody came back for.

It is tested against PostgreSQL, MySQL, SQL Server and SQLite, including twenty tasks racing to spend ten credits each from a balance of a hundred (exactly ten succeed) and sixteen racing to capture one hold (one charge).

Bill a subscription when customers pay by transfer or QRIS

A subscription cannot pull money from a customer who pays by virtual account, QRIS or an e-wallet: it can only ask. So each cycle rustlavel-billing raises an invoice and a charge, and waits; the access is extended when the gateway says it was paid. Enable the billing feature, which brings payment and ledger with it, and create the tables of both.

use rustlavel::billing::{Billing, Channel, Money, Paid, Plan, Subscriber};
use rustlavel::payment::{Bank, FakeGateway};
use std::sync::Arc;

async fn billing(db: Database, ledger: Ledger) -> rustlavel::Result<()> {
    let plans = vec![
        Plan::new("starter", "Starter", Money::idr(49_000), 100),
        Plan::new("pro", "Pro", Money::idr(149_000), 500).grace(Duration::from_secs(3 * 86_400)),
    ];
    // A fake here; a real gateway driver in production.
    let gateway = Arc::new(FakeGateway::new("webhook-secret"));
    let billing = Billing::new(db, ledger, gateway, plans);

    // The first invoice, with the charge the customer pays.
    let invoice = billing
        .subscribe(Subscriber::new("user:42", "Ada"), "pro", Channel::VirtualAccount(Bank::Bca))
        .await?;
    println!("pay to {:?}", invoice.instructions.account_number);

    // When the gateway's webhook says it was paid:
    if let Paid::Extended { subscription, .. } = billing.on_paid(&invoice.id).await? {
        println!("served until {}", subscription.period_end);
    }

    // On a schedule, every few minutes:
    let tick = billing.tick().await?;
    for reminder in tick.reminders {
        println!("tell {} about {:?}", reminder.subscriber.name, reminder.kind);
    }
    Ok(())
}

on_paid is idempotent — a retried webhook finds the credits already in the ledger and the invoice already paid, and is told Paid::Already. tick raises renewals ahead of the period's end, moves an unpaid subscription into grace, then suspends it, and returns the reminders that fell due; the words and the sending are yours. It is safe to run from two places at once.

No real payment gateway ships yet. rustlavel-payment defines the Gateway trait — charges over virtual account, QRIS, e-wallet and retail, transfers, and a webhook receiver that verifies signatures and deduplicates — and a FakeGateway for tests. A driver for your provider implements that trait.

Draw a chart under a strict Content-Security-Policy

rustlavel-chart builds a Chart.js configuration in Rust and serves Chart.js itself from your own origin, so a page under default-src 'self' — which refuses both a CDN and an inline <script> — can still draw. Enable the chart feature, register the plugin once, and hand the configuration to the template:

App::new()?.plugin(Charts::default()).routes(|r: &mut Router| {
    r.get("/dashboard", dashboard);
})
use rustlavel::chart::{Axis, Chart, Charts, Series};
use rustlavel::prelude::*;

async fn dashboard(req: Request) -> rustlavel::Result<Response> {
    let revenue = Chart::line("Revenue")
        .labels(["Jan", "Feb", "Mar"])
        .series(Series::new("2026", [1200.0, 1800.0, 1500.0]).filled())
        .y(Axis::new().prefix("Rp "));

    req.view("dashboard", &ViewContext::new().with("revenue", revenue.to_json()))
}
<script src="/vendor/chart/chart.umd.js" defer></script>
<script src="/vendor/chart/rustlavel-chart.js" defer></script>

<canvas data-chart="{{ revenue }}" height="220"></canvas>

The configuration travels in an escaped attribute, which is why no inline script is needed: a label reading a" onmouseover="alert(1) arrives as that string.

Your stylesheet must be a file too. That policy blocks an inline <style> exactly as it blocks an inline script, and a chart follows the page's theme through two CSS custom properties, --chart-ink and --chart-grid. Put them in a stylesheet the page links; in a <style> block they are silently never applied.

Background work

Queue a job

Needs queue.

#[derive(Job)]
pub struct SendWelcome { pub user_id: i64 }

impl SendWelcome {
    pub async fn handle(&self, ctx: &Context) -> Result<()> { … }
}

// Anywhere:
queue.push(SendWelcome { user_id: user.id }).await?;
rustlavel queue:work            # a worker
rustlavel queue:failed          # what died, and why

Run something on a schedule

App::new()?.schedule(
    Scheduler::new()
        .job("0 3 * * *", PruneOldSessions)     // 03:00 daily
        .job("*/15 * * * *", RefreshExchangeRates),
)
rustlavel schedule:run           # from cron, once a minute

Talking to other services

Call an API without making an outage worse

Needs client.

let http = Client::new().retries(2).breaker(CircuitBreaker::new());

match http.get("https://api.example.com/rates").send().await {
    Ok(response) => use_them(response.json()?),
    // Nothing was sent, so nothing can have had an effect — which is
    // exactly when falling back to a cached answer is safe.
    Err(Error::Unavailable(_)) => cached_rates(),
    Err(error) => return Err(error),
}

Send mail

Needs mail.

mailer.send(
    Message::new()
        .to(user.email.as_str())
        .subject("Your receipt")
        .text(format!("Thanks, {}. Your receipt is attached.", user.name))
        .attach(Attachment::new("receipt.pdf", pdf_bytes)),
).await?;

In a test, assert instead of sending:

let mailer = Mail::fake();
// … run the thing …
let fake = mailer.fake().expect("faking");
assert_eq!(fake.count(), 1);
assert!(fake.sent_to("ada@example.com"));

Sign somebody in through Google or GitHub

Needs oauth.

r.get("/auth/github", |req: Request| async move {
    let provider = req.state::<OAuthClient>().expect("registered in main.rs");
    let (url, verifier) = provider.authorize_url(&["read:user"]);
    // PKCE: the verifier stays here, only its hash goes to the provider.
    req.session().put("_pkce", Json::from(verifier));
    Response::see_other(url)
});

Operating it

Turn something on for some people

Needs flags.

if req.flag("new-checkout").await? {
    return new_checkout(req).await;
}

And when it goes wrong at three in the morning, without a deploy:

FLAGS_OFF=new-checkout

Take a new release of the starter kit

rustlavel upgrade --dry-run
rustlavel upgrade

Three-way, not two: the version you started from, what you have now, and what the release ships. Changes only one side made apply on their own; changes both sides made are written with conflict markers so the build fails rather than an upgrade half-applying. Files the kit never wrote are never touched. See the guide for the flags.

Answer a load balancer

App::new()?.plugin(
    Health::new().check("database", |req| {
        let db = req.state::<Database>().cloned();
        async move {
            db.ok_or("no database")?
              .scalar::<i64>("select 1", &[]).await
              .map(|_| ()).map_err(|e| e.to_string())
        }
    }),
)

GET /up says the process is alive and is what restarts it. GET /up/ready runs the checks and says whether it can do useful work, and is what routes traffic to it. Pointing a liveness probe at the second one turns a database outage into a restart loop.

Find out why a request was slow

App::new()?
    .middleware(RequestId::new())     // one id, in the log and on the response
    .plugin(Telescope::default())     // /telescope, refuses to mount in production
    .plugin(Debugbar::default())      // this page's queries, drawn on the page

The identifier on the response is the one to ask a user for: it appears in every log line for that request and on the http.request event that Telescope, the metrics endpoint and the OTLP exporter all read.

Deploy it as one file

rustlavel build --release          # a single binary, assets embedded
rustlavel make:docker              # a two-stage Dockerfile, if you would rather

Testing

Exercise a route without a socket

#[rustlavel::test]
async fn a_guest_cannot_reach_the_dashboard() {
    let client = app().test_client();
    let response = client.get("/dashboard").await;
    response.assert_redirect("/login");
}

#[rustlavel::test]
async fn a_bad_form_comes_back_with_its_errors() {
    let client = app().test_client();
    client.get("/posts/create").await;
    client.post("/posts", &[("title", "")]).await.assert_status(303);
    client.get("/posts/create").await.assert_see("The title field is required.");
}

Test the database with no server

SQLite is the one database that needs no container. sqlite://:memory: is a private database that exists for the length of the test and nothing longer. Turn on the sqlite feature for your tests:

#[tokio::test]
async fn a_post_is_stored_and_found() -> rustlavel::Result<()> {
    // A private database in memory: no container, no environment variable,
    // and gone when the test ends.
    let db = Database::connect("sqlite://:memory:").await?;

    Schema::new(&db)
        .create("posts", |t| {
            t.id();
            t.string("title");
        })
        .await?;

    db.table("posts").insert(&db, &[("title", "Hello".into())]).await?;

    let found = db.table("posts").filter("title", "Hello").first(&db).await?;
    assert!(found.is_some());
    Ok(())
}

This proves your queries and migrations. It does not prove how a server behaves: locking and concurrency differ between databases, which is why the framework's own concurrency tests run against each real one rather than against SQLite alone.

Test against a real database, or skip cleanly

The pattern the framework uses on itself: a suite that needs a server reads its URL from the environment and reports a skip when there is none, so cargo test stays green on a laptop with no Docker.

macro_rules! server {
    ($variable:literal) => {
        match std::env::var($variable) {
            Ok(url) if !url.is_empty() => url,
            _ => { eprintln!("skipping: {} is not set", $variable); return; }
        }
    };
}

Give every test its own name-spaced rows or tables. The suite runs concurrently, and two tests sharing a fixture fail in a way that looks like a bug in the code rather than in the tests.

What is missing from here

This page grows with the questions people ask. If something you needed was not here, it is worth saying so — a recipe that somebody needed and could not find is the most useful thing to add.