Skip to content

Renox: Laravel's workflow, compiled into one Rust binary

· 4 min read · Renoxium

Renox is the framework this site runs on. It takes the shape of a Laravel app (routes, models, migrations, validation, queues, mail, a scheduler, generators) and builds it on Rust: Axum for HTTP, MiniJinja for templates, SQLite or PostgreSQL for data, htmx and Alpine.js in the browser. Everything comes in one renox dependency, and everything compiles into one executable.

This post walks through what an app is made of, with code from this site and from Renox’s examples.

The app is a value

A Renox app is built in a function, app(), as a value of type App. main.rs only runs it, and every test boots the same value:

pub fn app() -> renox::App {
    renox::App::new()
        .embed(renox::embedded!())         // views, translations, public files
        .migrations(renox::migrations!())  // migrations/*.sql, inside the binary
        .module(app::home::Home)
        .module(app::site::Site)           // the pages and the contact form
        .module(app::blog::Blog)           // what you're reading
}

That is this whole site. An app with accounts adds .module(Auth::new()), one more line. There’s no service container to configure and no files discovered at runtime. If a module isn’t in that list, it isn’t in the app, and the compiler checks the list.

A feature is a module

Each feature lives in src/app/<name>/ and implements Module: a name and its routes, and optionally its own migrations, jobs, event listeners and scheduled tasks. This blog is the whole pattern (a few routes left out):

pub struct Blog;

impl Module for Blog {
    fn name(&self) -> &'static str {
        "blog"
    }

    fn routes(&self) -> Routes {
        Routes::new()
            .get("/writing", index)
            .name("blog.index")
            .get("/writing/feed.xml", feed::atom)
            .name("blog.atom")
            .get("/writing/{slug}", show)
            .name("blog.show")
            .etag() // a 304 when the page hasn't changed
    }
}

Layers and guards such as .etag(), .require_auth() or .require_permission(…) cover the routes added before them in that Routes. Once you know that rule, the order of calls is all there is to it. rnx route:list prints every route with its name, its module and its guards, so you can check the result.

Handlers are typed functions

A handler is an async function whose arguments are extractors. If an argument can’t be built (no session, a missing row, invalid input), the handler never runs. Here is one from an admin area that changes a user’s roles:

async fn update_roles(
    State(db): State<Db>,
    admin: AuthUser,             // logged in, or a redirect to /login
    Path(id): Path<i64>,         // /users/{id}/roles
    Valid(form): Valid<RolesForm>, // validated, or a 422 with the errors
) -> Result<(Toast, HxRefresh)> {
    // ...
}

The return type is also the response. (Toast, HxRefresh) shows a toast and tells htmx to reload the page; (Toast, Redirect) flashes the toast and redirects. Every combination like this is checked when the code compiles.

Forms validate themselves

Rules sit on the form’s fields, written the way Laravel writes them:

#[derive(Deserialize, Validate)]
struct ContactForm {
    #[validate(required, max = 100, label = "Your name")]
    name: String,
    #[validate(required, email, max = 255)]
    email: String,
    #[validate(max = 100)]
    company: Option<String>,
    #[validate(required, min = 10, max = 5000)]
    message: String,
}

That is this site’s contact form.

When rules depend on each other, or need the database, you implement Validate by hand with three hooks: prepare tidies the input, authorize answers 403 before any rule runs, and after checks the database and puts its errors on a field. The handler only ever sees valid data.

Views are rendered on the server

Templates are MiniJinja (Jinja2’s syntax), with a UI kit of macros for forms, tables, sheets, toasts and data grids:

{% from "renox/ui.html" import input, textarea, button %}
<form method="post" action="{{ route('contact.send') }}" data-live-validate novalidate>
  {{ csrf_field() }}
  {{ input("email", "Email", type="email", required=true, autocomplete="email") }}
  {{ textarea("message", "Message", rows=6, required=true) }}
  {{ button("Send message") }}
</form>

The kit labels every field for screen readers, refills the fields after a failed post and shows each error under its field. A plain post that fails comes back as a redirect to the form, with the errors and the input flashed. An htmx request gets a 422 with the errors as JSON instead, and the bundled renox.js puts each one next to its input and focuses the first, without resetting what was typed. data-live-validate checks fields as people leave them. None of this takes JavaScript of your own, and a Content-Security-Policy is on by default.

The CLI writes the boilerplate

rnx is Renox’s command-line tool. Instead of writing a CRUD by hand, you generate one, tests included:

rnx make:module products --resource --fields "name:string price:money"
rnx make:model Invoice --key ulid --migration
rnx make:job SendReceipt --module products
rnx serve        # rebuilds on save; the browser reloads when a view changes

The generated code is plain Rust in your app. There’s no runtime magic behind it, so you can read it, change it or delete it.

One binary

rnx build compiles the views, translations, CSS, JavaScript and migrations into the executable. A release is that file plus a .env: no Node build on the server, no Redis, no separate worker process (the queue and the scheduler run inside serve). The first post here covers why that, together with SQLite, makes pages fast.

Where to start

The tutorial builds an app from rnx new to a deploy. If you know Laravel, the Laravel guide maps each concept to its Renox equivalent. The next posts here go deeper: tests that travel in time, catching N+1 queries, deploys that refuse no connections, and an admin panel without a JavaScript framework.

  1. · 4 min read A back office without a JavaScript framework Data grids with filters, search, grouping, row actions and exports, plus charts, all rendered on the server by Renox, with htmx for the parts that update in place.
  2. · 3 min read Catching N+1 queries before they ship Renox has no lazy relations on purpose. What it has instead: loaders that fetch a page's related rows in one query each, a debug page that flags repeated SQL, and tests that count queries.
  3. · 3 min read Tests that travel in time How Renox tests a whole app in memory: real requests, a clock you can move forward, and fake HTTP that never reaches the network.

Get new posts by Atom, RSS or JSON Feed.