Rustlavel

Guide

From nothing to a running application with a database behind it.

Install the CLI

cargo install rustlavel-cli

This gives you rustlavel, which is the Artisan equivalent: it scaffolds projects, generates controllers and migrations, runs a development server, and diagnoses a misconfigured application.

Scaffold a project

rustlavel new blog --with db,view,auth
cd blog
rustlavel serve

The skeleton is deliberately slim, following Laravel 11 and later: routes, one controller, config, a public directory, tests. Everything else arrives as a package.

--with is the scaffold-time form of cargo add. What you do not ask for is never compiled into your binary, so an application with no mailer does not ship one. That is a real difference rather than a slogan — the benchmark found a comparable Loco application shipping an SMTP client and three cloud storage backends it never used.

Routes

Routes are ordinary Rust, registered on a Router. There is no route file scanned at runtime and no attribute macro reflecting over your handlers.

use rustlavel::prelude::*;

pub fn routes(r: &mut Router) {
    r.get("/", |_req: Request| async move {
        Response::text("Hello, World!")
    });

    r.get("/users/{id}", controllers::users::show).name("users.show");

    r.group("/admin", |admin| {
        admin.middleware(Authenticate::default());
        admin.get("/dashboard", controllers::admin::dashboard);
    });
}

Parameters come out of the request by name, groups carry a prefix and a middleware stack, and a named route can be reversed with url_for.

The database

A connection is application state, so a handler asks for it rather than reaching for a global.

let db = Database::connect("postgres://user:pass@localhost/app?sslmode=verify-full").await?;
App::new()?.state(db).routes(routes).run().await

Set sslmode deliberately. It defaults to prefer, matching libpq — which asks for encryption and accepts a server that declines, so it guarantees nothing against an attacker who can answer "no TLS". Anything crossing a network you do not own wants verify-full. Running rustlavel doctor will say so out loud.

The query builder cannot be injected into: values are bound, identifiers are validated against an allowlist, and an update or delete with no filter is refused rather than quietly emptying a table.

let posts = db.table("posts")
    .filter("published", true)
    .order_by("created_at", Direction::Desc)
    .limit(20)
    .get(&db)
    .await?;

Which database

The scheme of the URL chooses the driver, and everything above the driver — the query builder, migrations, models, the queue — is written once and does not know which database it is talking to.

postgres://user:pass@host/app
mysql://user:pass@host/app
sqlserver://user:pass@host/app
sqlite://data/app.db            relative to where the process runs
sqlite:///var/lib/app.db        absolute: one more slash
sqlite://:memory:               a private database that vanishes with the process

PostgreSQL, MySQL and SQL Server each speak their own wire protocol, written here. SQLite is the exception, and an opt-in one. It is not a server — the library is the database, reading a file in your own process — so there is no protocol to write, and it links a C library. It is off unless you ask for it, so a build that does not never needs a C compiler:

rustlavel = { version = "0.9", features = ["db", "sqlite"] }

It is for tests that need no container and for small tools that want a file. Two things it does on purpose, because SQLite's own defaults would make a migration behave differently on a server: foreign keys are switched on for every connection (SQLite leaves them off), and :memory: is one shared database for the whole Database rather than a blank one per connection. Writes are serialised by SQLite itself, one writer at a time, which is plenty for a file and not a substitute for a server under load.

Models and relations

#[derive(Model)]
#[model(table = "posts")]
struct Post {
    id: i64,
    title: String,
    user_id: i64,
}

The derive is a hand-written proc macro — no syn, no quote — and its errors are written to be read by a person. Relations load without N+1: has_many and belongs_to fetch in one round trip for the whole set.

Migrations and seeders

rustlavel make:migration create_posts_table
rustlavel migrate
rustlavel migrate:rollback
rustlavel db:seed

The migration list is a generated registry rather than a directory scanned at boot. A compiled program cannot discover types by looking at files, and pretending otherwise would mean giving up the compile-time guarantees that are the point.

Templates

@extends("layouts.app")

@section("content")
  <h1>{{ title }}</h1>
  @foreach(posts as post)
    <article>{{ post.title }}</article>
  @endforeach
@endsection

Blade-shaped, with escaping that cannot be switched off by accident, and reload-from-disk while you are developing.

Making stored files public

Uploads belong under storage/app, outside the web root — a directory the server hands out verbatim is not one anybody should be able to write arbitrary files into. But some of those files are meant to be seen.

rustlavel storage:link

That links public/storage to storage/app/public, so one copy of each file is reachable over HTTP and the rest of storage/app stays out of reach. Running it again is not an error, which matters because it belongs in a deploy script.

It refuses to delete a real directory at public/storage, even with --force. That directory may hold files this command did not put there, and removing them is not a risk it gets to take on your behalf. --force repoints a link; it does not delete anybody's files.

Turning things on for some people

Runtime switches, resolved per user — not to be confused with the Cargo feature flags above, which are compile-time and decide what is in the binary at all.

App::new()?.plugin(FeatureFlags::new(
    Flags::from_config(app.config())
        .rollout("new-checkout", 5)          // five per cent, stably
        .define("beta-search", |scope| async move { is_staff(scope).await }),
))

// In a handler:
if req.flag("new-checkout").await? { … }
// Or on a whole route, which answers 404 when the flag is off:
r.get("/checkout/v2", checkout).middleware(WhenActive::new("new-checkout"));

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

FLAGS_OFF=new-checkout

That is read before the store is touched, so it works even when the store is what broke. It is the strongest thing in the chain for exactly that reason.

Building an API

An application that only serves JSON needs a handful of things a page-serving one does not, and every one of them is a middleware or a plugin you add by name. Nothing is on until you turn it on.

use rustlavel::prelude::*;
use std::time::Duration;

App::new()?
    .middleware(RequestId::new())
    .middleware(Cors::from_config(app.config()))
    .middleware(Compress::new())
    .middleware(ETag::new())
    .plugin(Health::new().check("database", |req| { … }))
    .routes(|r| {
        r.version("v1", |v1| {
            v1.middleware(Timeout::seconds(10));
            v1.middleware(BodyLimit::kilobytes(64));
            v1.middleware(Idempotency::new(&cache));
            v1.get("/users", UserController::index);
            v1.post("/payments", PaymentController::store);
        });
    })
    .run()
    .await

CORS is the one an API cannot be called from a browser without. It reads config/cors.json with the same keys as Laravel's config/cors.php, and every list also accepts a comma-separated string so it can come from .env. The default is closed: a forgotten line shows up in a browser console, not as an API open to the world.

Compression negotiates from Accept-Encoding and sends gzip, or zlib for deflate. The codec is written in the framework, like everything else, and its output is checked against the real gzip. A 300-user JSON list went from 17,671 bytes to 2,238 on the wire.

ETag tags every successful GET and answers a matching If-None-Match with a 304 and no body. The handler still runs — this saves bandwidth, not compute.

Resources give a type one JSON shape instead of one per controller:

pub struct UserResource;

impl JsonResource for UserResource {
    type Model = User;

    fn to_json(user: &User) -> Json {
        attributes()
            .set("id", user.id)
            .set("name", &user.name)
            .when(user.is_admin, "permissions", || Json::from(vec!["*"]))
            .finish()
    }
}

// In a controller:
UserResource::make(&user).created()
UserResource::collection(&page.hydrate()?)
    .paginate(page.current_page, page.per_page, page.total)

The shapes are Laravel's, down to the keys in meta and links. A when() that is false leaves the key absent, not null — a client not told about permissions knows it may not ask.

Request ids connect a user's screenshot to a line in the log. One is assigned per request, or kept from a proxy, and appears on the response, in req.request_id(), and on the http.request event that Telescope and OTLP see.

Idempotency keys make POST /payments safe to retry. The first request with an Idempotency-Key runs; every later one gets the first answer back, marked Idempotent-Replayed: true. The same key with a different body is a 422, and a request still in flight is a 409.

Health is two routes because they answer two questions. /up says the process is alive, and is what restarts it; /up/ready runs your checks and says whether it can do useful work, and is what routes traffic to it.

Timeout and BodyLimit are per group. The server has a body limit of its own that protects the process; this one is policy, so a JSON endpoint that takes a kilobyte does not accept the fifty megabytes the avatar upload needs.

Calling another service, add a circuit breaker. Retrying is the right answer to a request that failed by accident and the wrong answer to a service that is down: when an upstream stops answering, every caller retrying it turns one outage into three — the upstream cannot recover under the load, this application’s tasks all sit blocked on timeouts, and whoever called this application times out in turn.

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

match http.get("https://api.example.com/rates").send().await {
    Ok(response) => use_them(response),
    // 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),
}

It trips on a failure rate over a sliding window, not a raw count: five failures means something very different in ten calls than in ten thousand. Then it refuses calls outright for a pause, and lets a few probes through before resuming, so a service that has not come back does not get the full load the moment the pause ends. Breakers are kept per host, so a slow payment provider does not stop you talking to your search cluster. A 5xx counts against the upstream; a 404 is your own request being wrong and never trips it.

Behind a load balancer, add TrustProxies. Without it req.ip() is the balancer's address, which is true but not useful; with it, and only for a connection from an address you named, the client address the balancer reported is used instead. It is opt-in for a reason: reading X-Forwarded-For from anyone who sends it does not tell you where a request came from, it lets the sender choose — and the rate limiter keys on exactly that value.

Versions come by path with r.version("v2", ...) or by header with VersionHeader; either way the handler asks req.api_version(). Retiring one is .sunset("2027-01-01") on the route: every response then carries Sunset and Deprecation, so a client library can warn its own developers for months before the day.

Generators

Ten of them, and the two that do the most work:

rustlavel make:crud Post --fields "title:string,body:text,published:bool"

Writes a model, a migration, a resourceful controller and two views whose columns, struct fields, form inputs and table headers all agree, and appends the routes to src/routes/web.rs — a resource nobody wired up does not work. With the auth package on, the forms carry a CSRF token; without it they do not, and neither does the code that would fail to compile.

rustlavel tinker
rustlavel tinker -e 'Post::count(&db).await?'

A scratch crate that depends on yours, rebuilt per snippet: about 0.9 seconds the first time and 0.3 after. It is a scratchpad rather than a conversation — every statement costs a compile and nothing carries between them, which the command says on its first run rather than leaving you to work out.

The starter kit

Everything above builds an application from parts. If what you want is a working sign-in to start from, there is one:

rustlavel new app --with auth-kit
cd app
rustlavel key:generate
# set DATABASE_URL in .env
rustlavel migrate
rustlavel db:seed     # prints a link to set the first administrator’s password
rustlavel serve

That writes the controllers, views, routes and migrations into your project. The login page is resources/views/auth/login.rl.html and the controller behind it is src/controllers/auth/login_controller.rs. Both are yours: change them, delete what you do not need, and the code stops being reachable. This is the shape Laravel Breeze has, and for the same reason.

What stays in crates is the part that should not be copy-pasted — TOTP, the QR encoder, passkeys, roles and permissions. A security fix there reaches your application through cargo update, rather than through a diff nobody applies to a project scaffolded a year ago.

What you get

PageWhere it lives
Sign in, with a second factorresources/views/auth/login.rl.html, challenge.rl.html
Register, and the invitation an administrator sendsauth/register.rl.html, sent.rl.html
Choose a first password, or reset oneauth/activate.rl.html, forgot.rl.html
Recovery codesauth/recovery.rl.html
Dashboardresources/views/dashboard.rl.html
Profile and change passwordresources/views/profile.rl.html
Security: authenticator app, passkeys, recovery codes, sessionssettings/security.rl.html
Users, roles and permissionsresources/views/admin/

Registration, and why nobody types a password early

A visitor may sign up when AUTH_REGISTRATION_OPEN allows it, and an administrator may create the account instead. Both roads end at the same place: a link emailed to the address, behind which the person chooses their own password. So a password is never typed into a form belonging to an unconfirmed address, and an administrator never knows anybody’s.

The public form answers the same way whether or not the address already has an account. Saying “that email is taken” would turn it into a way to ask who has an account here.

Two factors

An authenticator app (TOTP) or a passkey, with single-use recovery codes behind both. The QR code on the enrolment page is rendered on the server as SVG: a chart API would mean posting the TOTP secret to a third party, and a JavaScript QR library would need a looser Content-Security-Policy than the kit ships with.

Recovery codes are not optional decoration. Without them a lost phone means a lost account, and an administrator ends up turning two-factor off with a database query.

Lockout, in two halves

Wrong passwords lock the account for fifteen minutes, stored on the user row so it survives a restart and applies wherever the attempt comes from. Separately, failures from one address are counted in the cache. Without that second half, somebody trying one common password against ten thousand accounts never trips a single account’s counter — and that is the attack that actually works.

Roles and permissions

Roles hold permissions, and a user may also be granted or denied one directly. An explicit direct deny beats everything, including the super role: a rule another role can quietly overrule is not a rule. A permission ending in .* covers everything below it, so a role holding users.* covers users.create.

// In a route:
admin.get("/users", UsersController::index).middleware(Can::permission("users.view"));

// In a handler:
if req.can("users.delete").await? { … }

Viewing as somebody else

Support asks “what does this user see?” and impersonation is how you look. The session keeps who really signed in, so the audit trail names the administrator rather than the person they were looking at, and a loud banner sits across every page — forgetting you are impersonating and then changing something is the failure this exists to prevent. Somebody who may impersonate cannot themselves be impersonated, which is what stops one administrator’s session becoming every administrator’s.

The interface

Tailwind v4, built to public/css/app.css and committed, so running the result needs no Node toolchain. Rebuild after using a class no page used before:

npx @tailwindcss/cli -i resources/css/app.css -o public/css/app.css --minify

There is no inline style, no inline script and no external origin anywhere in the markup. That is deliberate: the pages are meant to be served under a Content-Security-Policy with no unsafe-inline, and a policy that has to be loosened for the framework’s own output protects nothing. The light and dark theme is a cookie the server reads, not a class JavaScript adds after the page has painted — the usual fix for that flash is a small inline script in the head, which is precisely what the policy forbids.

Keeping the kit up to date

The kit is a hundred files copied into your project, and from the moment they land they are yours. That is what makes them worth having — and it used to be a dead end, because a release that fixed one of them could reach nobody. Every project created with 0.5.0 was still running 0.5.0’s kit.

rustlavel upgrade --dry-run   # say what would change, write nothing
rustlavel upgrade

It reconciles three versions of every file rather than two: what the version in .rustlavel/manifest.json wrote, what is in your project now, and what the CLI you are running would write today. Where one side moved, that side wins and you are asked nothing. Where both moved to the same place, so does the merge. Where both moved differently, the file is written with <<<<<<< markers and the project stops compiling until you decide — an upgrade that could not decide must not be able to pass unnoticed.

Cargo.toml is kept in step too: the rustlavel dependency is bumped, and any feature flag the kit has come to need is added to whatever your project already enables. Nothing you enabled yourself is removed, and a path dependency is left alone.

The base comes from crates.io. Every release carries its templates inside the published .crate, so whichever version your project was created with is a download away, and downloads are cached. Files the kit no longer ships are reported, never deleted: it cannot know what came to depend on them. New environment variables are appended; existing ones are never rewritten, because a value in your .env is a deployment’s own.

FlagWhat it does
--dry-runReport without writing anything
--from <version>The base version, for a project created before 0.7.2 — those have no manifest
--allow-dirtyDo not require a clean git tree first

A dirty tree is refused by default because git checkout is the way back, and a clean tree is what makes it available. Run your tests before committing the result: a merge that applied cleanly is still a merge.

What this does not touch is as important. A file the kit never wrote is not in scope at all, so your own controllers, views, models and migrations are never read, never merged and never reported.

Upgrading to 0.7.2

Concrete, because the general shape above does not tell you what a real jump costs. Every step here was run against a project scaffolded by the published 0.5.0 CLI.

cargo install rustlavel-cli --version 0.7.2 --force
cd your-project
git status                          # must be clean
git checkout -b upgrade-0.7.2

1. Look first. --from is required, because a project created before 0.7.2 has no .rustlavel/manifest.json to read the base version out of.

rustlavel upgrade --from 0.5.0 --dry-run

2. Apply. On an unedited 0.5.0 project this reports 61 merged, 17 added, 36 already current.

rustlavel upgrade --from 0.5.0

3. Check Cargo.toml. From 0.7.3 this is done for you. On 0.7.2 exactly, edit the line yourself:

rustlavel = { version = "0.7.2", features = ["audit", "auth", "cache", "db", "i18n", "mail", "rbac", "validation", "view", "webauthn"] }

The one that matters is i18n: 0.5.0 never enabled it, and the @lang directive the kit now uses does not compile without it. Keep any feature your project enabled on its own — this list is added to, not substituted for.

4. Resolve what conflicted. The report separates two things that are not the same. “Both you and the new version changed these” are real conflicts: read both sides. “Both versions are in these, because there was nothing to compare against” are files 0.5.0 did not have, so neither side can be preferred — usually take the new one. On an unedited project that is only src/lib.rs, six pub mod lines.

grep -rl '^<<<<<<< ' src resources database config

5. Build. An unedited 0.5.0 project compiles in about twenty seconds once step 4 is done.

cargo build

6. Migrate. Additive and safe: the migrator skips what has already run, keyed by filename, and the registry merges correctly.

cargo run -- migrate:status     # see what is new before running it
cargo run -- migrate

7. Seeders — do not run db:seed plain. This is the one part still unsolved, and it is worth saying so rather than letting somebody find out. New settings, permissions and menu entries live in the seeder, not in a migration, and the kit’s seeder will overwrite rows that have been edited since. An idempotent seeder that inserts only what is missing does not exist yet. Until it does: read database/seeders/auth_kit_seeder.rs, compare its permissions and settings against the live tables, and insert the missing rows by hand.

8. Rebuild the stylesheet, but only if you had edited resources/css/app.css.

npx @tailwindcss/cli -i resources/css/app.css -o public/css/app.css --minify

Two things not to worry about: .env gains new keys and is never rewritten, so production credentials are untouched; and files outside the kit are never read at all. Run your own tests before merging the branch — a merge that applied cleanly is still a merge.

Several services instead of one

The other starter kit scaffolds a cargo workspace rather than one application:

rustlavel new shop --with microservices-kit
cd shop
cargo build --workspace

Four services and a shared crate. The gateway is one door in front of the rest. The authorization server is a complete OAuth 2.1 provider. The resource server holds your domain. The registry is optional — see below. And shared/ holds the types that cross between them, and nothing else: the moment two services share a model they share a schema, then a migration, and then they are one service deployed twice.

A database per service, and none for the gateway. The authorization server owns users, clients, codes and tokens; the resource server owns its domain and never reads the other’s schema. The moment a gateway owns a table it stops being a gateway and becomes a fourth service that can fail — the one every request already passes through.

The resource server checks tokens itself. The gateway refuses a request with no credential, and that saves a hop and nothing else: a gateway is not the only door. Anything on the network can reach a service directly, and a check that happens only at the edge is missing the moment somebody adds a second way in. Both ways of checking are supported and carry the same tests — introspection against the authorization server, or a local ES256 signature check — because a security path that is rarely used is the one that is wrong without anybody knowing. Pick with TOKEN_VERIFICATION in .env.

The difference between them is how long a revoked token keeps working, and it is worth stating as a number rather than as a word. Introspection caches its answer for thirty seconds, so revocation takes up to that long — not immediately, which is what people usually assume. A local signature check has nothing to ask, so revocation waits for the token to expire: an hour. Thirty seconds against an hour, then, and the cache is what buys you a request to the authorization server per token per window rather than one per call.

Everything the authorization server issues is stored in its own database. Clients, codes, access and refresh tokens and consent each have a table, so restarting it is a deploy rather than a mass sign-out. Spending a code and rotating a refresh token are single statements the database adjudicates — a code cannot be spent twice and a refresh token cannot be rotated twice, whatever arrives at the same moment.

Finding the services

Out of the box the gateway uses the two addresses in .env. That is the right answer on Kubernetes or ECS, where the platform already knows which instances are ready and already balances across them — and it is the right answer for one of each on a laptop. Running a registry there would be a second copy of a fact the platform is already keeping, and two sources for one fact disagree eventually.

Where it earns its place: services on plain machines, an estate spread across two clouds, or a Spring system already speaking Eureka that a Rustlavel service has to join. Set one line and the arrangement changes:

# .env
DISCOVERY_URL=http://127.0.0.1:8761
SERVICE_HOST=10.0.0.7      # an address other machines can reach, not 127.0.0.1

The gateway then resolves auth and api for every request and balances across whatever is registered, so a new instance takes traffic as soon as it starts. Each service announces itself on the way up and heartbeats for the life of the process. A service that resolves to nothing is 503 — the gateway is working and there is nothing behind it, which is not the 502 that means an instance refused the request.

SERVICE_HOST is the one to get right: the registry hands out what it is told, so a service that registers 127.0.0.1 has told every caller in the estate to talk to itself.

Run more than one registry by listing every node in PEERS — the same list on each, since each node drops its own address. Writes are forwarded to the others, and a forwarded write is never forwarded on, so three nodes do not pass one registration between them forever.

Running the four services

They share one .env, so the port cannot live in it — all four would read the same SERVER_PORT and fight over one socket. A real environment variable beats anything in the file, which is what makes these work:

SERVER_PORT=8761 cargo run -p shop_registry
SERVER_PORT=9001 cargo run -p shop_auth
SERVER_PORT=9002 cargo run -p shop_api
SERVER_PORT=9000 cargo run -p shop_gateway

Before that, once: cargo run -p shop_auth -- migrate and the same for shop_api, then cargo run -p shop_auth -- db:seed. The seeder prints two client credentials and will not print them again — one for the resource server to introspect with, and one that can actually call the API so you can try it out. They are separate on purpose: the introspection secret lives in the resource server’s own configuration, and giving it the scopes your domain checks would let anybody who reads that file mint a token that writes.

A new instance takes up to thirty seconds to receive traffic, because the gateway caches what it read from the registry for that long. That is Eureka’s interval and the trade is deliberate: the alternative is a lookup on every request, against a service that is then on the critical path of every request.

Three things the registry does that look like bugs

It keeps nothing on disk. Every instance re-registers within a heartbeat of a restart, so the state rebuilds itself faster than it could be loaded — and a stored copy would only ever be wrong on the way back up.

It sometimes serves instances that are gone. When most of the registry goes quiet at once, a network problem is far more likely than every service dying in the same second, so it says so and keeps serving rather than emptying itself. A registry that empties during a blink turns the blink into an outage that outlasts it, because everything then has to re-register before anything can find anything. The dashboard shows a banner while this is happening, which is the difference between a five-minute diagnosis and an afternoon. A registry holding fewer than eight instances never does it: a proportion threshold applied to a handful fires on the first ordinary failure.

Clients keep their last answer forever. When the registry cannot be reached, a lookup is served from what was last read, for however long that lasts. The outage costs you changes — a new instance is not noticed, a departed one is still offered — and your own retries cover the second. A cache that expired would turn a registry outage into a total outage on a timer.

A dashboard

The registry serves one at /discovery. There is no authentication in front of it, and it lists every host and port you run: keep that port private, or turn it off with .dashboard(None).

The other way is to let the auth kit show it. rustlavel new asks:

Add a dashboard for a service registry? [y/N]

Answer yes and you get /admin/discovery behind a discovery.view permission, in the kit’s own layout. That page reads the registry’s JSON document over HTTP rather than embedding a registry — a dashboard that went down with the thing it is there to tell you about would be a dashboard nobody could use at the only moment it matters. When the registry does not answer, the page says which address it tried instead of showing an error page.

To skip the question, ask for it on the command line: rustlavel new app --with auth-kit,discovery-ui.

Valkey

There is no separate Valkey package, and that is a decision rather than a gap. Valkey is the Linux Foundation fork of Redis 7.2 and speaks Redis’s protocol unchanged, so a second client would be the existing one with the name changed — and two copies of a protocol implementation drift apart exactly once, at the worst moment. The one client answers to both names:

rustlavel new app --with valkey

That enables the cache package, writes config/cache.json, and puts this in .env:

CACHE_DRIVER=valkey
CACHE_URL=valkey://127.0.0.1:6379

valkey:// and redis:// parse identically, VALKEY_URL is read where REDIS_URL is, and every error names the scheme you wrote. Sessions work the same way: RedisSessions::connect("valkey://…"). The whole integration suite runs against Valkey 8 and passes unchanged — measured, not inferred from the protocol being the same.

What is different from Laravel

LaravelRustlavel
Facades and a runtime containerTyped application state, resolved at compile time
Auto-discovered service providersOne explicit .plugin(...) line
Migrations found by scanning a folderA registry the CLI generates
DB::transaction(fn () => ...)A guard: begin, commit, rolls back when dropped

The transaction difference is not taste. A closure returning a future that borrows its argument cannot express the lifetime it needs, so the guard is what the language actually allows.

One service, different per request

req.state::<Database>() reads the request’s own copy first and falls back to the application’s. Middleware can put one there:

router.middleware(|mut request: Request, next: Next| Box::pin(async move {
    let tenant = resolve_tenant(&request).await?;
    request.extend(pool_for(tenant).await?);   // this request’s Database
    next.run(request).await
}));

Every handler underneath goes on writing req.state::<Database>() and is talking to the right database without knowing that tenants exist. That is the whole of database-per-tenant multi-tenancy from a controller’s point of view; the alternative is threading a lookup through every handler, where one missed call site reads another company’s data.

The override lasts exactly one request — there is a test for that, because a tenant connection leaking into the next visitor’s request is the worst thing this could do. A request that was given nothing gets what main.rs registered, which is every request in an application that has no such middleware.

When the port is taken

Address already in use is the most common way starting an application fails, usually because the last run has not finished dying. Outside production the server walks up from the port it was asked for and tells you where it went:

WARN  port 8399 is in use, so this is serving on 8400 instead
INFO  Rustlavel serving on http://127.0.0.1:8400

In production it does not move. There, something depends on the number — a load balancer, a health check, a firewall rule — and a server that quietly went to 8301 while the traffic still went to 8300 would look like an outage with no cause in the logs. It refuses to start and says what is holding the port instead. Set server.port_attempts to change either default: 1 to make a busy port an error during development, a larger number to let production wander.

Only AddrInUse walks on. A privileged port, or an address that is not on this machine, is still that error — walking away from those would only make them harder to see.

Diagnosing a problem

rustlavel doctor

Checks the toolchain, .env, the application key, the port, whether the database is reachable, what the connection is actually encrypted with, the directory layout, and whether the thing compiles.

Where to go next

The packages page lists every optional crate. The full API reference for each is on docs.rs, and examples/blog is a worked application that is compiled by CI on every push.