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
| Page | Where it lives |
|---|---|
| Sign in, with a second factor | resources/views/auth/login.rl.html, challenge.rl.html |
| Register, and the invitation an administrator sends | auth/register.rl.html, sent.rl.html |
| Choose a first password, or reset one | auth/activate.rl.html, forgot.rl.html |
| Recovery codes | auth/recovery.rl.html |
| Dashboard | resources/views/dashboard.rl.html |
| Profile and change password | resources/views/profile.rl.html |
| Security: authenticator app, passkeys, recovery codes, sessions | settings/security.rl.html |
| Users, roles and permissions | resources/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.
| Flag | What it does |
|---|---|
--dry-run | Report without writing anything |
--from <version> | The base version, for a project created before 0.7.2 — those have no manifest |
--allow-dirty | Do 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
| Laravel | Rustlavel |
|---|---|
| Facades and a runtime container | Typed application state, resolved at compile time |
| Auto-discovered service providers | One explicit .plugin(...) line |
| Migrations found by scanning a folder | A 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.