Controllers & Requests

  1. Controllers
  2. Form Requests: #[derive(FormRequest)]
    1. The rule vocabulary
    2. It only reads form bodies
  3. AppError: what a handler can fail with
  4. Next

Controllers

A controller is a plain struct with async fn methods - no base class, no constructor injection, no $this. Each method is an ordinary Axum handler:

pub struct PostController;

impl PostController {
    pub async fn show(
        session: Session,
        cookies: CookieJar,
        post: Post,                          // route model binding - see Routing
    ) -> Result<impl IntoResponse, AppError> {
        let author_name = post.user().await?
            .map(|author| author.name)
            .unwrap_or_else(|| "Unknown".to_string());

        Ok(view!("posts.show", { title: post.title, author_name }))
    }

    pub async fn store(
        session: Session,
        Auth(user): Auth<User>,              // requires login - see Authentication
        request: StorePostRequest,           // validated before this method runs
    ) -> Result<impl IntoResponse, AppError> {
        let validated = request.validated();
        let post = Post::create(NewPost {
            user_id: user.id,
            title: validated.title,
            content: validated.content,
        }).await?;

        Ok(larust_support::redirect()
            .route("posts.index")?
            .with(&session, "success", "Post created.")
            .await)
    }
}

Every parameter is a real Axum extractor, resolved in argument order before your method body runs - Session, CookieJar, a route-bound Post, an Auth<User> guard, a FormRequest struct. If any extractor fails (no session, no logged-in user, validation errors, model not found), your handler body never executes at all - the extractor’s own Rejection becomes the response.

There’s no controller base class to extend and nowhere to put $this->middleware(...) - middleware attaches at the route instead (.middleware(...)/.group(...), see Routing), which is also why a controller method has no implicit access to “the current request” beyond whatever it explicitly declares as a parameter.

Form Requests: #[derive(FormRequest)]

The direct equivalent of Laravel’s Form Request classes - a struct that is both your validation rules and your typed, validated input, resolved as a single Axum extractor:

use larust_support::FormRequest;

#[derive(FormRequest)]
pub struct RegisterRequest {
    #[validate(required, length(max = 255))]
    pub name: String,

    #[validate(required, email)]
    pub email: String,

    // Checks against a sibling `password_confirmation` field by
    // convention (Laravel's own `{field}_confirmation` naming) - no
    // second field needs to be declared here for it.
    #[validate(required, length(min = 8), confirmed)]
    pub password: String,
}

Declare it as a handler parameter and it does the rest:

pub async fn register(request: RegisterRequest) -> Result<impl IntoResponse, AppError> {
    let validated = request.validated();
    // validated.name / validated.email / validated.password are all
    // present and already checked - if execution reached this line at
    // all, extraction already succeeded.
}

If any rule fails, the handler body never runs at all - extraction itself returns a 422 Unprocessable Entity with a JSON body describing every failing field, generated entirely by the derive macro:

{
  "errors": {
    "email": ["is required", "must be a valid email address"],
    "password": ["must be at least 8 characters"]
  }
}

.validated() doesn’t perform any further validation itself - by the time a RegisterRequest value exists at all, extraction already checked it. It exists purely for call-site parity with Laravel’s $request->validated().

The rule vocabulary

Attribute Checks
required Field is present and non-empty
email A syntactically valid email address
string No-op - every field is already a String; recognized so it reads naturally next to Laravel’s own rule string
confirmed Matches a sibling {field}_confirmation field
length(max = N) At most N characters
length(min = N) At least N characters
length(min = N, max = N) Both, in one attribute

An unrecognized rule name is a compile error, not a silently-ignored no-op - one real advantage over a string-based rule pipeline. A field can carry more than one #[validate(...)] attribute; rules are deduplicated and checked in first-seen order.

Two real, current limits worth knowing up front. Every field must be String - other types (numbers, booleans, Option<T>) aren’t supported yet, so a numeric field is validated and read as a string and parsed separately in your handler if you need it as one. And there’s no unique:table,column-style database-backed rule - checking uniqueness against the database is something your handler does explicitly after .validated(), the same way you’d write any other query. Neither is a silent gap: an unsupported rule name is a compile error, and xr convert flags a Laravel unique:* rule in its report rather than dropping it quietly.

It only reads form bodies

A FormRequest’s extraction reads application/x-www-form-urlencoded (or multipart form) data - the same shape an HTML <form> submits, or TestClient::post_form sends in a test. It is not a JSON-body extractor; for a JSON API endpoint, use Axum’s own Json<T> extractor directly and validate however that route needs to.

AppError: what a handler can fail with

Every handler in this book returns Result<impl IntoResponse, AppError>. AppError is a small, fixed enum:

pub enum AppError {
    NotFound,                                  // 404
    Http { status: StatusCode, message: String }, // any specific status + message
    Internal(Box<dyn Error + Send + Sync>),    // 500, logged, hidden from the client unless APP_DEBUG
    Config(Box<dyn Error + Send + Sync>),      // 500, a startup/config problem
}

larust_support::auth::authorize(bool) - used throughout this framework for policy checks - is just a thin wrapper: Ok(()) if true, Err(AppError::Http { status: FORBIDDEN, .. }) if false. See Error Handling for how each variant actually renders, and APP_DEBUG’s effect on what a client sees.

Next

Views & Templates covers view!(...) and .blade.xr - what Ok(view!("posts.show", { ... })) above is actually doing.