A laravel-inspired web framework for Rust.
Install the CLI from the repo root:
cargo install --path .
# If you don't need this anymore:
# cargo uninstall willow-forge
Then scaffold a new application:
willow-forge new my-app
cd my-app
cargo run
The server starts on http://localhost:3000.
Alternatively, run the CLI without installing:
cd {willow-forge root}
cargo run -- new my-app
cd my-app
docker compose -f docker/docker-compose.yml up -d --build
cargo run --manifest-path ../Cargo.toml -- migrate
cargo run
my-app/
├── app/
│ ├── errors.rs ← AppError (unified error type)
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── HomeController.rs
│ │ │ ├── UserController.rs
│ │ │ └── StatusController.rs
│ │ ├── Middleware/
│ │ │ └── LogRequest.rs
│ │ └── Requests/
│ │ └── StoreUserRequest.rs
│ ├── Models/
│ │ └── User.rs
│ └── Providers/
│ └── AppServiceProvider.rs
├── bootstrap/
│ ├── lib.rs ← library root, bootstrap() lives here
│ ├── app_state.rs
│ ├── context.rs
│ ├── validated_json.rs
│ ├── view.rs
│ └── middleware.rs ← global / api / web middleware groups
├── config/
│ ├── app.toml
│ └── database.toml
├── database/
│ └── migrations/
├── resources/
│ └── views/
│ ├── layouts/
│ │ └── app.jinja.html
│ └── welcome.jinja.html
├── routes/
│ ├── api.rs
│ └── web.rs
├── src/
│ └── main.rs
├── .env
└── Cargo.toml
Routes live in routes/web.rs (HTML) and routes/api.rs (JSON).
Each file returns an axum::Router<Arc<AppState>>:
// routes/web.rs
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(home_controller::index))
}
// routes/api.rs
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route("/api/users", get(user_controller::index).post(user_controller::store))
.route("/api/users/mock", get(user_controller::mock))
.route("/api/status", get(status_controller::index))
}Both routers are merged in src/main.rs with middleware applied:
let app = middleware::global(
middleware::api(api::routes())
.merge(middleware::web(web::routes())),
)
.with_state(app_state);Willow Forge does DI via Arc<AppState> passed through the axum router state.
Defined in bootstrap/app_state.rs. Inner fields are plain values — no nested Arc:
pub struct AppState {
pub config: Config,
pub services: Services, // PgPool is Arc-based internally
pub views: ViewEngine, // MiniJinja Environment is Arc-based internally
}Context is an axum extractor that pulls Arc<AppState> out of the router state.
Add it as the first parameter of any handler:
pub async fn index(ctx: Context) -> Result<impl IntoResponse, AppError> {
let app_name = &ctx.state.config.app_name;
// ...
}bootstrap/lib.rs wires everything together at startup:
- Reads
.env - Builds
Configfrom environment variables - Initialises the view engine from
resources/views/ - Creates the database pool via
AppServiceProvider - Returns
Arc<AppState>
AppError is defined in willow-forge-runtime and re-exported as use my_app::AppError.
pub enum AppError {
NotFound, // 404
Unauthorized, // 401
Forbidden, // 403
Validation(ValidationError), // 422
Conflict(String), // 409
ServiceUnavailable, // 503
TooManyRequests, // 429
Http(u16, String), // any status code
View(ViewError), // 500
Database(sqlx::Error), // 500
Redis(redis::RedisError), // 500
Internal, // 500
}Controllers return Result<impl IntoResponse, AppError>. From impls let ? propagate errors automatically:
// ViewError → AppError::View via ?
pub async fn index(ctx: Context) -> Result<impl IntoResponse, AppError> {
Ok(view(&ctx, "welcome", context! { ... })?)
}
// sqlx::Error → AppError::Database via ?
let users = sqlx::query_as::<_, User>(...).fetch_all(pool).await?;
// Known conflict → AppError::Conflict
.map_err(|e| match e {
sqlx::Error::Database(ref db) if db.constraint() == Some("users_email_key")
=> AppError::Conflict("Email already taken.".to_string()),
other => AppError::Database(other),
})?
// 503 maintenance
Err(AppError::ServiceUnavailable)
// arbitrary status code
Err(AppError::Http(451, "Unavailable For Legal Reasons".to_string()))| AppError variant | HTTP status | Body |
|---|---|---|
NotFound |
404 | {"message":"Not found"} |
Unauthorized |
401 | {"message":"Unauthorized"} |
Forbidden |
403 | {"message":"Forbidden"} |
Validation |
422 | {"message":"The given data was invalid.","errors":{...}} |
Conflict(msg) |
409 | {"message":"<msg>"} |
ServiceUnavailable |
503 | {"message":"Service unavailable"} |
TooManyRequests |
429 | {"message":"Too many requests"} |
Http(code, msg) |
code |
{"message":"<msg>"} |
View / Database / Redis / Internal |
500 | {"message":"Internal server error"} |
For browser requests, the exception handler automatically renders HTML views from resources/views/errors/:
errors/404.jinja.html— 404 specific (includes back link)errors/500.jinja.html— 500 specificerrors/generic.jinja.html— fallback for all other codes (401, 403, 429, 503, …)
To customise a specific code, create resources/views/errors/{code}.jinja.html. Variables available: code, message, app_name, app_env.
Return errors directly in route definitions without a controller:
// routes/web.rs — maintenance mode for a specific route
.route("/maintenance", get(|| async { Err::<(), _>(AppError::ServiceUnavailable) }))
// Per-group fallback (e.g. admin-only area)
let admin = Router::new()
.route("/admin/dashboard", get(dashboard))
.fallback(|| async { Err::<(), _>(AppError::Http(403, "Admin only".to_string())) });To put the whole app into maintenance mode, change the global fallback in src/main.rs:
async fn maintenance() -> impl axum::response::IntoResponse {
AppError::ServiceUnavailable
}
// .fallback(maintenance)bootstrap/middleware.rs is the single place to manage middleware — analogous to Laravel's Kernel.php.
// Runs on every request
pub fn global(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {
router.layer(middleware::from_fn(log_request::handle))
}
// API routes only
pub fn api(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {
router
// .layer(middleware::from_fn(auth::handle))
}
// Web (HTML) routes only
pub fn web(router: Router<Arc<AppState>>) -> Router<Arc<AppState>> {
router
// .layer(middleware::from_fn(csrf::handle))
}Generate a new middleware skeleton:
willow-forge make:middleware Auth
This creates app/Http/Middleware/Auth.rs with a handle() stub and instructions for registering it in bootstrap/middleware.rs.
Error views live in resources/views/errors/ as .jinja.html files.
app/Exceptions/Handler.rs intercepts every error response and renders the matching view when it exists.
| Template | Rendered when |
|---|---|
resources/views/errors/404.jinja.html |
404 Not Found |
resources/views/errors/500.jinja.html |
500 Internal Server Error |
resources/views/errors/{code}.jinja.html |
Any other status code |
Variables available in every error view: code, message, app_name, app_env.
If no matching view exists the original JSON response (AppError::IntoResponse) is passed through unchanged.
Undefined routes are caught by the .fallback() handler in src/main.rs and converted to AppError::NotFound:
// src/main.rs — generated automatically
async fn not_found() -> impl axum::response::IntoResponse {
AppError::NotFound
}
// ...
.fallback(not_found)app/Exceptions/Handler.rs decides whether to render an HTML view or pass through JSON using expects_json(), which mirrors Laravel's $request->expectsJson():
| Request | Result |
|---|---|
Browser (Accept: text/html) |
HTML error view |
curl (no Accept header) |
HTML error view |
curl -H "Accept: application/json" |
JSON |
Axios / XHR (X-Requested-With: XMLHttpRequest) |
JSON |
fetch() with explicit JSON Accept |
JSON |
To force JSON for all /api/* routes regardless of headers (common Laravel pattern via shouldRenderJsonWhen), edit expects_json() in app/Exceptions/Handler.rs:
fn expects_json(request: &Request) -> bool {
let is_api = request.uri().path().starts_with("/api/");
// ... existing header checks ...
is_api || wants_json || (is_ajax && accepts_any)
}Option 1 — Return a custom response directly from a controller (full override):
pub async fn show(ctx: Context) -> impl IntoResponse {
(StatusCode::NOT_FOUND, Json(json!({
"error": "user_not_found",
"message": "No user with that ID exists."
})))
}Option 2 — Edit app/errors.rs to change the JSON format for an existing error:
AppError::NotFound => (
StatusCode::NOT_FOUND,
Json(json!({ "error": "not_found", "message": "Resource not found" })),
).into_response(),Option 3 — Add a new variant to AppError for a specific case:
#[error("User not found: {0}")]
UserNotFound(i32), // maps to 404 with the user's IDOpen app/Exceptions/Handler.rs to add shared logic for all errors — logging, alerting, custom headers:
pub async fn render(State(state): State<Arc<AppState>>, request: Request, next: Next) -> Response {
let response = next.run(request).await;
let status = response.status();
if status.is_server_error() {
tracing::error!("Server error: {}", status); // add custom logic here
}
// render error view if available, otherwise pass through
// ...
}Views live under resources/views/ as .jinja.html files.
The underlying engine is MiniJinja (Jinja2 syntax).
use minijinja::context;
use my_app::{AppError, Context};
use my_app::view::view;
pub async fn index(ctx: Context) -> Result<impl IntoResponse, AppError> {
Ok(view(&ctx, "welcome", context! {
app_name => ctx.state.config.app_name.clone(),
})?)
}Dot notation maps to nested folders:
| Name | File |
|---|---|
"welcome" |
resources/views/welcome.jinja.html |
"users.index" |
resources/views/users/index.jinja.html |
"layouts.app" |
resources/views/layouts/app.jinja.html |
Variable output (HTML-escaped by default):
{{ app_name }}
Conditionals:
{% if user %}
<p>Hello, {{ user.name }}</p>
{% else %}
<p>Hello, guest</p>
{% endif %}
Loops:
{% for user in users %}
<li>{{ user.name }}</li>
{% endfor %}
Layout inheritance:
In the child view:
{% extends "layouts.app" %}
{% block content %}
<h1>Hello</h1>
{% endblock %}
In layouts/app.jinja.html:
<body>
{% block content %}{% endblock %}
</body>Includes:
{% include "partials.nav" %}
Willow Forge uses sqlx for database access. PostgreSQL is the only supported database in v1.
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=willowforge
DB_USERNAME=postgres
DB_PASSWORD=postgres
willow-forge migrate # run pending migrations
willow-forge make:migration add_posts_table # create a new migration pair
willow-forge migrate:rollback # undo last migration
willow-forge migrate:status # list applied / pending
willow-forge migrate:fresh # drop all + re-run
willow-forge migrate:reset # rollback all
Migration files live in database/migrations/ as .up.sql / .down.sql pairs.
#[derive(Debug, Clone, Serialize, Deserialize, FromRow)]
pub struct User {
pub id: i32,
pub name: String,
pub email: String,
pub created_at: DateTime<Utc>,
}pub async fn index(ctx: Context) -> Result<impl IntoResponse, AppError> {
let pool = &ctx.state.services.db;
let users = sqlx::query_as::<_, User>(
"SELECT id, name, email, created_at FROM users ORDER BY id",
)
.fetch_all(pool)
.await?; // sqlx::Error → AppError::Database
Ok(Json(json!({ "data": users })))
}Unique constraint violations map to AppError::Conflict (409):
.map_err(|e| match e {
sqlx::Error::Database(ref db) if db.constraint() == Some("users_email_key")
=> AppError::Conflict("Email already taken.".to_string()),
other => AppError::Database(other),
})?Willow Forge includes a Laravel-style Cache facade backed by a Redis Cluster.
A single Arc<ClusterClient> is shared across all requests as services.redis.
The Cache facade and direct Redis access both use the same client.
If the cluster is down the app still boots — connections are established lazily per request.
REDIS_CLUSTER_NODES=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
Provide comma-separated seed node URLs. The client auto-discovers the full cluster topology.
Start the cluster with docker compose up.
use std::time::Duration;
use myapp::{Cache, Context, AppError};
// Get or compute-and-store (most common pattern)
let users = Cache::remember(&ctx, "users.all", Duration::from_secs(300), || async {
sqlx::query_as::<_, User>("SELECT * FROM users")
.fetch_all(&ctx.state.services.db)
.await
.map_err(AppError::from)
}).await?;
// Simple get / put
Cache::put(&ctx, "greeting", &"hello", Duration::from_secs(60)).await?;
let val: Option<String> = Cache::get(&ctx, "greeting").await?;
// Delete
Cache::forget(&ctx, "greeting").await?;
// Counters
Cache::increment(&ctx, "page.views").await?;| Method | Description |
|---|---|
Cache::get::<T>(&ctx, key) |
Retrieve value; None on cache miss |
Cache::put(&ctx, key, &val, ttl) |
Store with TTL |
Cache::put_forever(&ctx, key, &val) |
Store with no expiry |
Cache::remember(&ctx, key, ttl, || async {...}) |
Get or compute and store |
Cache::remember_forever(&ctx, key, || async {...}) |
Remember without TTL |
Cache::forget(&ctx, key) |
Delete a key |
Cache::flush(&ctx) |
FLUSHDB on the cluster node serving the key namespace |
Cache::has(&ctx, key) |
Check key existence |
Cache::increment / Cache::decrement |
Integer counters |
use redis::AsyncCommands;
let mut conn = ctx.state.services.redis.get_async_connection().await?;
let _: () = conn.set_ex("raw:key", "value", 60u64).await?;
let val: Option<String> = conn.get("raw:key").await?;Request structs live in app/Http/Requests/. Derive Deserialize and Validate:
#[derive(Debug, Deserialize, Validate)]
pub struct StoreUserRequest {
#[validate(length(min = 1, max = 255))]
pub name: String,
#[validate(email)]
pub email: String,
#[validate(length(min = 8))]
pub password: String,
}Use ValidatedJson<T> in handlers:
pub async fn store(
ctx: Context,
ValidatedJson(req): ValidatedJson<StoreUserRequest>,
) -> Result<impl IntoResponse, AppError> {
// req is fully validated here
}| Situation | Status | Body |
|---|---|---|
| Malformed JSON | 400 | {"message":"Invalid JSON: ..."} |
| Validation failure | 422 | {"message":"The given data was invalid.","errors":{...}} |
| Command | Description |
|---|---|
willow-forge new <name> |
Scaffold a new application |
willow-forge make:controller <Name> |
Create app/Http/Controllers/<Name>.rs |
willow-forge make:request <Name> |
Create app/Http/Requests/<Name>.rs |
willow-forge make:model <Name> |
Create app/Models/<Name>.rs |
willow-forge make:view <name> |
Create a view (dot notation supported) |
willow-forge make:migration <name> |
Create a timestamped migration pair |
willow-forge make:middleware <name> |
Create app/Http/Middleware/<Name>.rs |
willow-forge migrate |
Run pending migrations |
willow-forge migrate:rollback |
Roll back the last migration |
willow-forge migrate:status |
Show applied / pending migrations |
willow-forge migrate:fresh |
Drop all tables and re-run all migrations |
willow-forge migrate:reset |
Roll back all migrations |
Use
cargo runto start the application. There is nowillow-forge servecommand.