Ferro Framework

A Laravel-inspired web framework for Rust.

Ferro brings the developer experience of Laravel to Rust, providing familiar patterns and conventions while leveraging Rust's safety and performance.

Features

  • Routing - Expressive route definitions with middleware support
  • Database - SeaORM integration with migrations and models
  • Validation - Laravel-style validation with declarative rules
  • Authentication - Session-based auth with guards
  • Inertia.js - Full-stack React/TypeScript with compile-time validation
  • Events - Event dispatcher with sync/async listeners
  • Queues - Background job processing with Redis
  • Notifications - Multi-channel notifications (mail, database, slack)
  • Broadcasting - WebSocket channels with authorization
  • Storage - File storage abstraction (local, S3)
  • Caching - Cache with tags support
  • Testing - Test utilities and factories

Quick Example

#![allow(unused)]
fn main() {
use ferro::*;

#[handler]
pub async fn index(req: Request) -> Response {
    let users = User::find().all(&db).await?;

    Inertia::render(&req, "Users/Index", UsersProps { users })
}

pub fn routes() -> Router {
    Router::new()
        .get("/users", index)
        .middleware(AuthMiddleware)
}
}

Philosophy

Ferro aims to be the "Laravel of Rust" - a batteries-included framework that lets you build web applications quickly without sacrificing Rust's guarantees.

Convention over configuration - Sensible defaults that work out of the box.

Developer experience - Clear error messages, helpful CLI, and comprehensive documentation.

Type safety - Compile-time validation of routes, components, and queries.

Performance - Async-first design built on Tokio.

Getting Started

Ready to start building? Head to the Installation guide.

Installation

Requirements

  • Rust 1.75+ (with Cargo)
  • Node.js 18+ (for frontend)
  • PostgreSQL, SQLite, or MySQL

Installing the CLI

Install the Ferro CLI globally:

cargo install ferro-cli

Or build from source:

git clone https://github.com/albertogferrario/ferro.git
cd ferro
cargo install --path ferro-cli

Creating a New Project

ferro new my-app

This will:

  1. Create a new directory my-app
  2. Initialize a Rust workspace
  3. Set up the frontend with React and TypeScript
  4. Configure the database
  5. Initialize git repository

Options

# Skip interactive prompts
ferro new my-app --no-interaction

# Skip git initialization
ferro new my-app --no-git

Starting Development

cd my-app
ferro serve

This starts both the backend (port 8000) and frontend (port 5173) servers.

Server Options

# Custom ports
ferro serve --port 3000 --frontend-port 3001

# Backend only
ferro serve --backend-only

# Frontend only
ferro serve --frontend-only

# Skip TypeScript generation
ferro serve --skip-types

AI Development Setup

For AI-assisted development with Claude, Cursor, or VS Code:

ferro boost:install

This configures the MCP server and adds project guidelines for your editor.

Next Steps

Quick Start

Build a simple user listing feature in 5 minutes.

1. Create Migration

ferro make:migration create_users_table

Edit src/migrations/m_YYYYMMDD_create_users_table.rs:

#![allow(unused)]
fn main() {
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Users::Table)
                    .col(ColumnDef::new(Users::Id).big_integer().primary_key().auto_increment())
                    .col(ColumnDef::new(Users::Name).string().not_null())
                    .col(ColumnDef::new(Users::Email).string().not_null().unique_key())
                    .col(ColumnDef::new(Users::CreatedAt).timestamp().not_null())
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager.drop_table(Table::drop().table(Users::Table).to_owned()).await
    }
}

#[derive(Iden)]
enum Users {
    Table,
    Id,
    Name,
    Email,
    CreatedAt,
}
}

Run the migration:

ferro migrate

2. Sync Database to Models

ferro db:sync

This generates src/models/users.rs with SeaORM entity definitions.

3. Create Controller

ferro make:controller users

Edit src/controllers/users_controller.rs:

#![allow(unused)]
fn main() {
use ferro::*;
use crate::models::users::Entity as User;

#[handler]
pub async fn index(req: Request) -> Response {
    let db = req.db();
    let users = User::find().all(db).await?;

    Inertia::render(&req, "Users/Index", UsersIndexProps { users })
}

#[derive(InertiaProps)]
pub struct UsersIndexProps {
    pub users: Vec<crate::models::users::Model>,
}
}

4. Create Inertia Page

ferro make:inertia Users/Index

Edit frontend/src/pages/Users/Index.tsx:

import { InertiaProps } from '@/types/inertia-props';

interface User {
  id: number;
  name: string;
  email: string;
}

interface Props {
  users: User[];
}

export default function UsersIndex({ users }: Props) {
  return (
    <div className="container mx-auto p-4">
      <h1 className="text-2xl font-bold mb-4">Users</h1>
      <ul className="space-y-2">
        {users.map((user) => (
          <li key={user.id} className="p-2 border rounded">
            <strong>{user.name}</strong> - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
}

5. Add Route

Edit src/routes.rs:

#![allow(unused)]
fn main() {
use ferro::*;
use crate::controllers::users_controller;

pub fn routes() -> Router {
    Router::new()
        .get("/users", users_controller::index)
}
}

6. Generate TypeScript Types

ferro generate-types

This creates frontend/src/types/inertia-props.ts from your Rust props.

7. Run the Server

ferro serve

Visit http://localhost:5173/users to see your user listing.

What's Next?

Directory Structure

A Ferro project follows a convention-based structure inspired by Laravel.

my-app/
├── Cargo.toml              # Rust dependencies
├── .env                    # Environment configuration
├── src/
│   ├── main.rs             # Application entry point
│   ├── routes.rs           # Route definitions
│   ├── bootstrap.rs        # Global middleware registration
│   ├── actions/            # Business logic handlers
│   ├── controllers/        # HTTP controllers
│   ├── middleware/         # Custom middleware
│   ├── models/             # Database entities (SeaORM)
│   ├── migrations/         # Database migrations
│   ├── services/           # Service implementations
│   ├── requests/           # Form request validation
│   ├── events/             # Event definitions
│   ├── listeners/          # Event listeners
│   ├── jobs/               # Background jobs
│   ├── notifications/      # Notification classes
│   └── tasks/              # Scheduled tasks
├── frontend/
│   ├── src/
│   │   ├── pages/          # Inertia.js React components
│   │   ├── components/     # Reusable UI components
│   │   ├── types/          # TypeScript type definitions
│   │   └── main.tsx        # Frontend entry point
│   ├── package.json        # Node dependencies
│   └── vite.config.ts      # Vite configuration
├── storage/
│   ├── app/                # Application files
│   └── logs/               # Log files
└── public/
    └── storage/            # Symlink to storage/app/public

Key Directories

src/actions/

Business logic that doesn't fit neatly into controllers. Actions are invocable classes for complex operations.

#![allow(unused)]
fn main() {
// src/actions/create_user.rs
#[derive(Action)]
pub struct CreateUser {
    user_service: Arc<dyn UserService>,
}

impl CreateUser {
    pub async fn execute(&self, data: CreateUserData) -> Result<User> {
        // Business logic here
    }
}
}

src/controllers/

HTTP handlers grouped by resource. Generated with ferro make:controller.

#![allow(unused)]
fn main() {
// src/controllers/users_controller.rs
#[handler]
pub async fn index(req: Request) -> Response { ... }

#[handler]
pub async fn store(req: Request, form: CreateUserForm) -> Response { ... }
}

src/models/

SeaORM entity definitions. Generated with ferro db:sync.

src/middleware/

Custom middleware for request/response processing.

src/events/ and src/listeners/

Event-driven architecture. Events dispatch to multiple listeners.

src/jobs/

Background job definitions for queue processing.

frontend/src/pages/

Inertia.js page components. Path determines the route component.

pages/Users/Index.tsx  →  Inertia::render("Users/Index", ...)
pages/Dashboard.tsx    →  Inertia::render("Dashboard", ...)

Configuration Files

.env

Environment-specific configuration:

APP_ENV=local
APP_DEBUG=true
DATABASE_URL=sqlite:database.db
REDIS_URL=redis://localhost:6379

Cargo.toml

Rust dependencies. Ferro crates are added here.

frontend/package.json

Node.js dependencies for the React frontend.

Generated Directories

These directories are created automatically:

  • target/ - Rust build artifacts
  • node_modules/ - Node.js dependencies
  • frontend/dist/ - Built frontend assets

Storage

The storage/ directory holds application files:

# Create public storage symlink
ferro storage:link

This links public/storagestorage/app/public for publicly accessible files.

Routing

Ferro provides an expressive routing API similar to Laravel.

Basic Routes

Define routes in src/routes.rs:

#![allow(unused)]
fn main() {
use ferro::*;

pub fn routes() -> Router {
    Router::new()
        .get("/", home)
        .get("/users", users::index)
        .post("/users", users::store)
        .put("/users/:id", users::update)
        .delete("/users/:id", users::destroy)
}
}

Route Parameters

Parameters are extracted from the URL path:

#![allow(unused)]
fn main() {
#[handler]
pub async fn show(req: Request, id: i64) -> Response {
    // id is extracted from /users/:id
    let user = User::find_by_id(id).one(&db).await?;
    Ok(json!(user))
}
}

Optional Parameters

Use Option<T> for optional parameters:

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request, page: Option<i64>) -> Response {
    let page = page.unwrap_or(1);
    // ...
}
}

Route Groups

Group routes with shared middleware or prefixes:

#![allow(unused)]
fn main() {
pub fn routes() -> Router {
    Router::new()
        .group("/api", |group| {
            group
                .get("/users", api::users::index)
                .post("/users", api::users::store)
                .middleware(ApiAuthMiddleware)
        })
        .group("/admin", |group| {
            group
                .get("/dashboard", admin::dashboard)
                .middleware(AdminMiddleware)
        })
}
}

Named Routes

#![allow(unused)]
fn main() {
Router::new()
    .get("/users/:id", users::show).name("users.show")
}

Generate URLs:

#![allow(unused)]
fn main() {
let url = route("users.show", [("id", "1")]);
// => "/users/1"
}

Resource Routes

Generate CRUD routes for a resource:

#![allow(unused)]
fn main() {
Router::new()
    .resource("/users", users_controller)
}

This creates:

MethodURIHandler
GET/usersindex
GET/users/createcreate
POST/usersstore
GET/users/:idshow
GET/users/:id/editedit
PUT/users/:idupdate
DELETE/users/:iddestroy

Route Middleware

Apply middleware to specific routes:

#![allow(unused)]
fn main() {
Router::new()
    .get("/dashboard", dashboard)
    .middleware(AuthMiddleware)
}

Or to groups:

#![allow(unused)]
fn main() {
Router::new()
    .group("/admin", |group| {
        group
            .get("/users", admin::users)
            .middleware(AdminMiddleware)
    })
}

Fallback Routes

Handle 404s:

#![allow(unused)]
fn main() {
Router::new()
    .get("/", home)
    .fallback(not_found)
}

Route Constraints

Validate route parameters:

#![allow(unused)]
fn main() {
Router::new()
    .get("/users/:id", users::show)
    .where_("id", r"\d+")  // Must be numeric
}

API Routes

For JSON APIs, typically group under /api:

#![allow(unused)]
fn main() {
Router::new()
    .group("/api/v1", |api| {
        api
            .get("/users", api::users::index)
            .post("/users", api::users::store)
            .middleware(ApiAuthMiddleware)
    })
}

View Routes

For simple views without controller logic:

#![allow(unused)]
fn main() {
Router::new()
    .view("/about", "About", AboutProps::default())
}

Redirect Routes

#![allow(unused)]
fn main() {
Router::new()
    .redirect("/old-path", "/new-path")
    .redirect_permanent("/legacy", "/modern")
}

Route Caching

In production, routes are compiled at build time for optimal performance.

Middleware

Ferro provides a powerful middleware system for intercepting and processing HTTP requests before they reach your route handlers. Middleware can inspect, modify, or short-circuit requests, and also post-process responses.

Generating Middleware

The fastest way to create a new middleware is using the Ferro CLI:

ferro make:middleware Auth

This command will:

  1. Create src/middleware/auth.rs with a middleware stub
  2. Update src/middleware/mod.rs to export the new middleware

Examples:

# Creates AuthMiddleware in src/middleware/auth.rs
ferro make:middleware Auth

# Creates RateLimitMiddleware in src/middleware/rate_limit.rs
ferro make:middleware RateLimit

# You can also include "Middleware" suffix (same result)
ferro make:middleware CorsMiddleware

Generated file:

#![allow(unused)]
fn main() {
//! Auth middleware

use ferro::{async_trait, Middleware, Next, Request, Response};

/// Auth middleware
pub struct AuthMiddleware;

#[async_trait]
impl Middleware for AuthMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        // TODO: Implement middleware logic
        next(request).await
    }
}
}

Overview

Middleware sits between the incoming request and your route handlers, allowing you to:

  • Authenticate and authorize requests
  • Log requests and responses
  • Add CORS headers
  • Rate limit requests
  • Transform request/response data
  • And much more

Creating Middleware

To create middleware, define a struct and implement the Middleware trait:

#![allow(unused)]
fn main() {
use ferro::{async_trait, HttpResponse, Middleware, Next, Request, Response};

pub struct LoggingMiddleware;

#[async_trait]
impl Middleware for LoggingMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        // Pre-processing: runs before the route handler
        println!("--> {} {}", request.method(), request.path());

        // Call the next middleware or route handler
        let response = next(request).await;

        // Post-processing: runs after the route handler
        println!("<-- Request complete");

        response
    }
}
}

The handle Method

The handle method receives:

  • request: The incoming HTTP request
  • next: A function to call the next middleware in the chain (or the route handler)

You can:

  • Continue the chain: Call next(request).await to pass control to the next middleware
  • Short-circuit: Return a response early without calling next()
  • Modify the request: Transform the request before calling next()
  • Modify the response: Transform the response after calling next()

Short-Circuiting Requests

Return early to block a request from reaching the route handler:

#![allow(unused)]
fn main() {
use ferro::{async_trait, HttpResponse, Middleware, Next, Request, Response};

pub struct AuthMiddleware;

#[async_trait]
impl Middleware for AuthMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        // Check for Authorization header
        if request.header("Authorization").is_none() {
            // Short-circuit: return 401 without calling the route handler
            return Err(HttpResponse::text("Unauthorized").status(401));
        }

        // Continue to the route handler
        next(request).await
    }
}
}

Registering Middleware

Ferro supports three levels of middleware:

1. Global Middleware

Global middleware runs on every request. Register it in bootstrap.rs using the global_middleware! macro:

#![allow(unused)]
fn main() {
// src/bootstrap.rs
use ferro::{global_middleware, DB};
use crate::middleware;

pub async fn register() {
    // Initialize database
    DB::init().await.expect("Failed to connect to database");

    // Global middleware runs on every request (in registration order)
    global_middleware!(middleware::LoggingMiddleware);
    global_middleware!(middleware::CorsMiddleware);
}
}

2. Route Middleware

Apply middleware to individual routes using the .middleware() method:

#![allow(unused)]
fn main() {
// src/routes.rs
use ferro::{routes, get, post};
use crate::controllers;
use crate::middleware::AuthMiddleware;

routes! {
    get("/", controllers::home::index).name("home"),
    get("/public", controllers::home::public),

    // Protected route - requires AuthMiddleware
    get("/protected", controllers::dashboard::index).middleware(AuthMiddleware),
    get("/admin", controllers::admin::index).middleware(AuthMiddleware),
}
}

3. Route Group Middleware

Apply middleware to a group of routes that share a common prefix:

#![allow(unused)]
fn main() {
use ferro::Router;
use crate::middleware::{AuthMiddleware, ApiMiddleware};

Router::new()
    // Public routes (no middleware)
    .get("/", home_handler)
    .get("/login", login_handler)

    // API routes with shared middleware
    .group("/api", |r| {
        r.get("/users", list_users)
         .post("/users", create_user)
         .get("/users/{id}", show_user)
    })
    .middleware(ApiMiddleware)

    // Admin routes with auth middleware
    .group("/admin", |r| {
        r.get("/dashboard", admin_dashboard)
         .get("/settings", admin_settings)
    })
    .middleware(AuthMiddleware)
}

Middleware Execution Order

Middleware executes in the following order:

  1. Global middleware (in registration order)
  2. Route group middleware
  3. Route-level middleware
  4. Route handler

For responses, the order is reversed (post-processing happens in reverse order).

Request → Global MW → Group MW → Route MW → Handler
                                              ↓
Response ← Global MW ← Group MW ← Route MW ← Handler

Practical Examples

CORS Middleware

#![allow(unused)]
fn main() {
use ferro::{async_trait, Middleware, Next, Request, Response, HttpResponse};

pub struct CorsMiddleware;

#[async_trait]
impl Middleware for CorsMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        let response = next(request).await;

        // Add CORS headers to the response
        match response {
            Ok(mut res) => {
                res = res
                    .header("Access-Control-Allow-Origin", "*")
                    .header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE")
                    .header("Access-Control-Allow-Headers", "Content-Type, Authorization");
                Ok(res)
            }
            Err(mut res) => {
                res = res
                    .header("Access-Control-Allow-Origin", "*");
                Err(res)
            }
        }
    }
}
}

Rate Limiting Middleware

#![allow(unused)]
fn main() {
use ferro::{async_trait, Middleware, Next, Request, Response, HttpResponse};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

pub struct RateLimitMiddleware {
    requests: Arc<AtomicUsize>,
    max_requests: usize,
}

impl RateLimitMiddleware {
    pub fn new(max_requests: usize) -> Self {
        Self {
            requests: Arc::new(AtomicUsize::new(0)),
            max_requests,
        }
    }
}

#[async_trait]
impl Middleware for RateLimitMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        let count = self.requests.fetch_add(1, Ordering::SeqCst);

        if count >= self.max_requests {
            return Err(HttpResponse::text("Too Many Requests").status(429));
        }

        next(request).await
    }
}
}

Request Timing Middleware

#![allow(unused)]
fn main() {
use ferro::{async_trait, Middleware, Next, Request, Response};
use std::time::Instant;

pub struct TimingMiddleware;

#[async_trait]
impl Middleware for TimingMiddleware {
    async fn handle(&self, request: Request, next: Next) -> Response {
        let start = Instant::now();
        let path = request.path().to_string();

        let response = next(request).await;

        let duration = start.elapsed();
        println!("{} completed in {:?}", path, duration);

        response
    }
}
}

File Organization

The recommended file structure for middleware:

src/
├── middleware/
│   ├── mod.rs          # Re-export all middleware
│   ├── auth.rs         # Authentication middleware
│   ├── logging.rs      # Logging middleware
│   └── cors.rs         # CORS middleware
├── bootstrap.rs        # Register global middleware
├── routes.rs           # Apply route-level middleware
└── main.rs

src/middleware/mod.rs:

#![allow(unused)]
fn main() {
mod auth;
mod logging;
mod cors;

pub use auth::AuthMiddleware;
pub use logging::LoggingMiddleware;
pub use cors::CorsMiddleware;
}

Summary

FeatureUsage
Create middlewareImplement Middleware trait
Global middlewareglobal_middleware!(MyMiddleware) in bootstrap.rs
Route middleware.middleware(MyMiddleware) on route definition
Group middleware.middleware(MyMiddleware) on route group
Short-circuitReturn Err(HttpResponse::...) without calling next()
Continue chainCall next(request).await

Controllers

Controllers group related request handling logic.

Creating Controllers

ferro make:controller Users

This creates src/controllers/users_controller.rs:

#![allow(unused)]
fn main() {
use ferro::*;

#[handler]
pub async fn index(req: Request) -> Response {
    // List users
    Ok(json!({"users": []}))
}

#[handler]
pub async fn show(req: Request, id: i64) -> Response {
    // Show single user
    Ok(json!({"id": id}))
}

#[handler]
pub async fn store(req: Request) -> Response {
    // Create user
    Ok(json!({"created": true}))
}

#[handler]
pub async fn update(req: Request, id: i64) -> Response {
    // Update user
    Ok(json!({"updated": id}))
}

#[handler]
pub async fn destroy(req: Request, id: i64) -> Response {
    // Delete user
    Ok(json!({"deleted": id}))
}
}

The Handler Macro

The #[handler] macro provides:

  1. Automatic parameter extraction from path, query, body
  2. Dependency injection for services
  3. Error handling conversion
#![allow(unused)]
fn main() {
#[handler]
pub async fn show(
    req: Request,           // Always available
    id: i64,                // From path parameter
    user_service: Arc<dyn UserService>,  // Injected service
) -> Response {
    let user = user_service.find(id).await?;
    Ok(json!(user))
}
}

Route Registration

Register controller methods in src/routes.rs:

#![allow(unused)]
fn main() {
use crate::controllers::users_controller;

pub fn routes() -> Router {
    Router::new()
        .get("/users", users_controller::index)
        .get("/users/:id", users_controller::show)
        .post("/users", users_controller::store)
        .put("/users/:id", users_controller::update)
        .delete("/users/:id", users_controller::destroy)
}
}

Inertia Controllers

For Inertia.js responses:

#![allow(unused)]
fn main() {
use ferro::{Inertia, InertiaProps, Request, Response};
use crate::models::users::Entity as User;

#[handler]
pub async fn index(req: Request) -> Response {
    let db = req.db();
    let users = User::find().all(db).await?;

    Inertia::render(&req, "Users/Index", UsersIndexProps { users })
}

#[derive(InertiaProps)]
pub struct UsersIndexProps {
    pub users: Vec<crate::models::users::Model>,
}
}

Form Handling with SavedInertiaContext

When handling forms, you need to call req.input() which consumes the request. To render validation errors with Inertia, save the context first:

#![allow(unused)]
fn main() {
use ferro::{
    Inertia, InertiaProps, Request, Response, SavedInertiaContext, Validate, serde_json,
};

#[derive(InertiaProps)]
pub struct LoginProps {
    pub errors: Option<serde_json::Value>,
}

#[derive(Deserialize, Validate)]
pub struct LoginRequest {
    #[validate(email(message = "Please enter a valid email"))]
    pub email: String,
    #[validate(length(min = 1, message = "Password is required"))]
    pub password: String,
}

#[handler]
pub async fn login(req: Request) -> Response {
    // Save Inertia context BEFORE consuming request
    let ctx = SavedInertiaContext::from(&req);

    // This consumes the request
    let form: LoginRequest = req.input().await?;

    // Validate - use saved context for error responses
    if let Err(errors) = form.validate() {
        return Inertia::render_ctx(
            &ctx,
            "auth/Login",
            LoginProps { errors: Some(serde_json::json!(errors)) },
        ).map(|r| r.status(422));
    }

    // Process login...
    redirect!("/dashboard").into()
}
}

Key points:

  • SavedInertiaContext::from(&req) captures path and Inertia headers
  • Inertia::render_ctx(&ctx, ...) renders using saved context
  • Use this pattern when you need to both read the body AND render Inertia responses

Form Validation

Use form requests for validation:

#![allow(unused)]
fn main() {
use ferro::*;

#[derive(FormRequest)]
pub struct CreateUserRequest {
    #[validate(required, email)]
    pub email: String,

    #[validate(required, min(8))]
    pub password: String,
}

#[handler]
pub async fn store(req: Request, form: CreateUserRequest) -> Response {
    // form is already validated
    let user = User::create(form.email, form.password).await?;
    Ok(Redirect::to("/users"))
}
}

Service Injection

Inject services via the #[service] system:

#![allow(unused)]
fn main() {
#[handler]
pub async fn store(
    req: Request,
    form: CreateUserRequest,
    user_service: Arc<dyn UserService>,
    mailer: Arc<dyn Mailer>,
) -> Response {
    let user = user_service.create(form).await?;
    mailer.send_welcome(user.email).await?;

    Ok(Redirect::to("/users"))
}
}

Actions

For complex operations, use Actions:

ferro make:action CreateUser
#![allow(unused)]
fn main() {
// src/actions/create_user.rs
use ferro::*;

#[derive(Action)]
pub struct CreateUser {
    user_service: Arc<dyn UserService>,
    mailer: Arc<dyn Mailer>,
}

impl CreateUser {
    pub async fn execute(&self, data: CreateUserData) -> Result<User> {
        let user = self.user_service.create(data).await?;
        self.mailer.send_welcome(&user).await?;
        Ok(user)
    }
}
}

Use in controller:

#![allow(unused)]
fn main() {
#[handler]
pub async fn store(req: Request, form: CreateUserRequest, action: CreateUser) -> Response {
    let user = action.execute(form.into()).await?;
    Ok(Redirect::to(format!("/users/{}", user.id)))
}
}

Resource Controllers

A resource controller handles all CRUD operations:

MethodHandlerDescription
GET /resourcesindexList all
GET /resources/createcreateShow create form
POST /resourcesstoreCreate new
GET /resources/:idshowShow single
GET /resources/:id/editeditShow edit form
PUT /resources/:idupdateUpdate
DELETE /resources/:iddestroyDelete

API Controllers

For JSON APIs:

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request) -> Response {
    let users = User::find().all(&req.db()).await?;

    Ok(json!({
        "data": users,
        "meta": {
            "total": users.len()
        }
    }))
}
}

With pagination:

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request, page: Option<i64>, per_page: Option<i64>) -> Response {
    let page = page.unwrap_or(1);
    let per_page = per_page.unwrap_or(15);

    let paginator = User::find()
        .paginate(&req.db(), per_page as u64);

    let users = paginator.fetch_page(page as u64 - 1).await?;
    let total = paginator.num_items().await?;

    Ok(json!({
        "data": users,
        "meta": {
            "current_page": page,
            "per_page": per_page,
            "total": total
        }
    }))
}
}

Request & Response

The Request Object

Every handler receives a Request object:

#![allow(unused)]
fn main() {
#[handler]
pub async fn show(req: Request) -> Response {
    // Use request data
}
}

Accessing Request Data

#![allow(unused)]
fn main() {
// Path: /users/123
let id = req.param::<i64>("id")?;

// Query: /users?page=2&sort=name
let page = req.query::<i64>("page").unwrap_or(1);
let sort = req.query::<String>("sort");

// Headers
let auth = req.header("Authorization");
let content_type = req.content_type();

// Method and path
let method = req.method();
let path = req.path();
let full_url = req.url();
}

Request Body

#![allow(unused)]
fn main() {
// JSON body
let data: CreateUserData = req.json().await?;

// Form data
let form: HashMap<String, String> = req.form().await?;

// Raw body
let bytes = req.body_bytes().await?;
let text = req.body_string().await?;
}

File Uploads

#![allow(unused)]
fn main() {
let file = req.file("avatar").await?;

// File properties
let filename = file.filename();
let content_type = file.content_type();
let size = file.size();

// Save file
file.store("avatars").await?;
// or
file.store_as("avatars", "custom-name.jpg").await?;
}

Authentication

#![allow(unused)]
fn main() {
// Check if authenticated
if req.is_authenticated() {
    let user = req.user()?;
    println!("Hello, {}", user.name);
}

// Get optional user
if let Some(user) = req.user_optional() {
    // ...
}
}

Session Data

#![allow(unused)]
fn main() {
// Read session
let user_id = req.session().get::<i64>("user_id");

// Write session (needs mutable access)
req.session_mut().set("flash", "Welcome back!");

// Flash messages
let flash = req.session().flash("message");
}

Cookies

#![allow(unused)]
fn main() {
// Read cookie
let token = req.cookie("remember_token");

// Cookies are typically set on responses
}

Database Access

#![allow(unused)]
fn main() {
let db = req.db();
let users = User::find().all(db).await?;
}

Responses

Handlers return Response which is Result<HttpResponse, HttpResponse>:

#![allow(unused)]
fn main() {
pub type Response = Result<HttpResponse, HttpResponse>;
}

JSON Responses

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request) -> Response {
    Ok(json!({
        "users": users,
        "total": 100
    }))
}

// Or using HttpResponse directly
Ok(HttpResponse::json(data))
}

HTML Responses

#![allow(unused)]
fn main() {
Ok(HttpResponse::html("<h1>Hello</h1>"))
}

Text Responses

#![allow(unused)]
fn main() {
Ok(HttpResponse::text("Hello, World!"))
}

Inertia Responses

#![allow(unused)]
fn main() {
// Basic Inertia response
Inertia::render(&req, "Users/Index", props)

// With saved context (for form handlers)
let ctx = SavedInertiaContext::from(&req);
let form = req.input().await?;  // Consumes request
Inertia::render_ctx(&ctx, "Users/Form", props)
}

See Controllers - Form Handling for complete examples.

Redirects

#![allow(unused)]
fn main() {
// Simple redirect
Ok(Redirect::to("/dashboard"))

// Redirect back
Ok(Redirect::back(&req))

// Redirect with flash message
Ok(Redirect::to("/users").with("success", "User created!"))

// Named route redirect
Ok(Redirect::route("users.show", [("id", "1")]))
}

Status Codes

#![allow(unused)]
fn main() {
// Success codes
Ok(HttpResponse::ok(body))
Ok(HttpResponse::created(body))
Ok(HttpResponse::no_content())

// Error codes
Err(HttpResponse::bad_request("Invalid input"))
Err(HttpResponse::unauthorized("Please login"))
Err(HttpResponse::forbidden("Access denied"))
Err(HttpResponse::not_found("User not found"))
Err(HttpResponse::server_error("Something went wrong"))
}

Custom Status

#![allow(unused)]
fn main() {
Ok(HttpResponse::new(StatusCode::ACCEPTED)
    .json(data))
}

Response Headers

#![allow(unused)]
fn main() {
Ok(HttpResponse::json(data)
    .header("X-Custom", "value")
    .header("Cache-Control", "no-cache"))
}

Cookies

#![allow(unused)]
fn main() {
Ok(HttpResponse::json(data)
    .cookie(Cookie::new("token", "abc123"))
    .cookie(Cookie::new("remember", "true")
        .http_only(true)
        .secure(true)
        .max_age(Duration::days(30))))
}

File Downloads

#![allow(unused)]
fn main() {
// Download file
Ok(HttpResponse::download("path/to/file.pdf", "report.pdf"))

// Stream file
Ok(HttpResponse::file("path/to/video.mp4"))
}

Error Handling

Return errors as HttpResponse:

#![allow(unused)]
fn main() {
#[handler]
pub async fn show(req: Request, id: i64) -> Response {
    let user = User::find_by_id(id)
        .one(&req.db())
        .await?
        .ok_or_else(|| HttpResponse::not_found("User not found"))?;

    Ok(json!(user))
}
}

Domain Errors

Use #[domain_error] for typed errors:

#![allow(unused)]
fn main() {
#[domain_error]
pub enum UserError {
    #[error("User not found")]
    #[status(404)]
    NotFound,

    #[error("Email already taken")]
    #[status(409)]
    EmailTaken,
}

#[handler]
pub async fn store(req: Request) -> Response {
    let result = create_user().await?; // ? converts UserError to HttpResponse
    Ok(json!(result))
}
}

Form Requests

For automatic validation:

#![allow(unused)]
fn main() {
#[derive(FormRequest)]
pub struct CreateUserRequest {
    #[validate(required, email)]
    pub email: String,

    #[validate(required, min(8))]
    pub password: String,

    #[validate(same("password"))]
    pub password_confirmation: String,
}

#[handler]
pub async fn store(req: Request, form: CreateUserRequest) -> Response {
    // form is validated, safe to use
    Ok(json!({"email": form.email}))
}
}

If validation fails, Ferro automatically returns a 422 response with errors.

Events & Listeners

Ferro provides a Laravel-inspired event system for decoupling your application components. Events represent something that happened, while listeners react to those events.

Creating Events

Using the CLI

Generate a new event:

ferro make:event OrderPlaced

This creates src/events/order_placed.rs:

#![allow(unused)]
fn main() {
use ferro::Event;

#[derive(Clone)]
pub struct OrderPlaced {
    pub order_id: i64,
    pub user_id: i64,
    pub total: f64,
}

impl Event for OrderPlaced {
    fn name(&self) -> &'static str {
        "OrderPlaced"
    }
}
}

Event Requirements

Events must implement:

  • Clone - Events may be sent to multiple listeners
  • Send + Sync + 'static - For async safety
  • Event trait - Provides the event name

Creating Listeners

Using the CLI

Generate a new listener:

ferro make:listener SendOrderConfirmation

This creates src/listeners/send_order_confirmation.rs:

#![allow(unused)]
fn main() {
use ferro::{Listener, Error, async_trait};
use crate::events::OrderPlaced;

pub struct SendOrderConfirmation;

#[async_trait]
impl Listener<OrderPlaced> for SendOrderConfirmation {
    async fn handle(&self, event: &OrderPlaced) -> Result<(), Error> {
        tracing::info!("Sending confirmation for order {}", event.order_id);
        // Send email logic...
        Ok(())
    }
}
}

Listener Trait Methods

MethodDescriptionDefault
handle(&self, event)Process the eventRequired
name(&self)Listener identifierType name
should_stop_propagation(&self)Stop other listenersfalse

Registering Listeners

Register listeners in src/bootstrap.rs:

#![allow(unused)]
fn main() {
use ferro::{App, EventDispatcher};
use crate::events::OrderPlaced;
use crate::listeners::{SendOrderConfirmation, UpdateInventory, NotifyWarehouse};

pub async fn register() {
    // ... other setup ...

    let dispatcher = App::event_dispatcher();

    // Register listeners for OrderPlaced event
    dispatcher.listen::<OrderPlaced, _>(SendOrderConfirmation);
    dispatcher.listen::<OrderPlaced, _>(UpdateInventory);
    dispatcher.listen::<OrderPlaced, _>(NotifyWarehouse);
}
}

Closure Listeners

For simple cases, use closures:

#![allow(unused)]
fn main() {
dispatcher.on::<OrderPlaced, _, _>(|event| async move {
    tracing::info!("Order {} placed!", event.order_id);
    Ok(())
});
}

Dispatching Events

Call .dispatch() directly on events:

#![allow(unused)]
fn main() {
use crate::events::OrderPlaced;

// In a controller or service
OrderPlaced {
    order_id: 123,
    user_id: 456,
    total: 99.99,
}
.dispatch()
.await?;
}

Fire and Forget

Dispatch without waiting for listeners:

#![allow(unused)]
fn main() {
OrderPlaced {
    order_id: 123,
    user_id: 456,
    total: 99.99,
}
.dispatch_sync();  // Returns immediately
}

Using the Dispatcher Directly

#![allow(unused)]
fn main() {
use ferro::dispatch;

dispatch(OrderPlaced {
    order_id: 123,
    user_id: 456,
    total: 99.99,
}).await?;
}

Queued Listeners

For long-running tasks, queue listeners for background processing:

#![allow(unused)]
fn main() {
use ferro::{Listener, ShouldQueue, Error, async_trait};
use crate::events::OrderPlaced;

pub struct GenerateInvoicePDF;

// Mark as queued
impl ShouldQueue for GenerateInvoicePDF {
    fn queue(&self) -> &'static str {
        "invoices"  // Send to specific queue
    }

    fn delay(&self) -> Option<u64> {
        Some(30)  // Wait 30 seconds before processing
    }

    fn max_retries(&self) -> u32 {
        5
    }
}

#[async_trait]
impl Listener<OrderPlaced> for GenerateInvoicePDF {
    async fn handle(&self, event: &OrderPlaced) -> Result<(), Error> {
        // This runs in a background worker
        tracing::info!("Generating PDF for order {}", event.order_id);
        Ok(())
    }
}
}

Stopping Propagation

Stop subsequent listeners from running:

#![allow(unused)]
fn main() {
impl Listener<OrderPlaced> for FraudChecker {
    async fn handle(&self, event: &OrderPlaced) -> Result<(), Error> {
        if self.is_fraudulent(event) {
            return Err(Error::msg("Fraudulent order detected"));
        }
        Ok(())
    }

    fn should_stop_propagation(&self) -> bool {
        true  // Other listeners won't run if this fails
    }
}
}

Example: Order Processing

#![allow(unused)]
fn main() {
// events/order_placed.rs
#[derive(Clone)]
pub struct OrderPlaced {
    pub order_id: i64,
    pub user_id: i64,
    pub items: Vec<OrderItem>,
    pub total: f64,
}

impl Event for OrderPlaced {
    fn name(&self) -> &'static str { "OrderPlaced" }
}

// listeners/send_order_confirmation.rs
pub struct SendOrderConfirmation;

#[async_trait]
impl Listener<OrderPlaced> for SendOrderConfirmation {
    async fn handle(&self, event: &OrderPlaced) -> Result<(), Error> {
        let user = User::find(event.user_id).await?;
        Mail::to(&user.email)
            .subject("Order Confirmation")
            .template("emails/order-confirmation", event)
            .send()
            .await?;
        Ok(())
    }
}

// listeners/update_inventory.rs
pub struct UpdateInventory;

#[async_trait]
impl Listener<OrderPlaced> for UpdateInventory {
    async fn handle(&self, event: &OrderPlaced) -> Result<(), Error> {
        for item in &event.items {
            Product::decrement_stock(item.product_id, item.quantity).await?;
        }
        Ok(())
    }
}

// bootstrap.rs
dispatcher.listen::<OrderPlaced, _>(SendOrderConfirmation);
dispatcher.listen::<OrderPlaced, _>(UpdateInventory);
}

Best Practices

  1. Keep events immutable - Events are data, not behavior
  2. Use descriptive names - Past tense for things that happened (OrderPlaced, UserRegistered)
  3. Include all needed data - Listeners shouldn't need to fetch additional data
  4. Queue heavy operations - Use ShouldQueue for emails, PDFs, external APIs
  5. Handle failures gracefully - Listeners should not break on individual failures
  6. Test listeners in isolation - Unit test each listener independently

Queues & Background Jobs

Ferro provides a Redis-backed queue system for processing jobs asynchronously. This is essential for handling time-consuming tasks like sending emails, processing uploads, or generating reports without blocking HTTP requests.

Configuration

Environment Variables

Configure queues in your .env file:

# Queue driver: "sync" for development, "redis" for production
QUEUE_CONNECTION=sync

# Default queue name
QUEUE_DEFAULT=default

# Redis connection
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=0

Bootstrap Setup

In src/bootstrap.rs, initialize the queue system:

#![allow(unused)]
fn main() {
use ferro::{Queue, QueueConfig};

pub async fn register() {
    // ... other setup ...

    // Initialize queue (for production with Redis)
    if !QueueConfig::is_sync_mode() {
        let config = QueueConfig::from_env();
        Queue::init(config).await.expect("Failed to initialize queue");
    }
}
}

Creating Jobs

Using the CLI

Generate a new job:

ferro make:job ProcessPayment

This creates src/jobs/process_payment.rs:

#![allow(unused)]
fn main() {
use ferro::{Job, Error, async_trait};
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessPayment {
    pub order_id: i64,
    pub amount: f64,
}

#[async_trait]
impl Job for ProcessPayment {
    async fn handle(&self) -> Result<(), Error> {
        tracing::info!("Processing payment for order {}", self.order_id);
        // Payment processing logic...
        Ok(())
    }

    fn max_retries(&self) -> u32 {
        3
    }

    fn retry_delay(&self, attempt: u32) -> std::time::Duration {
        // Exponential backoff: 2s, 4s, 8s...
        std::time::Duration::from_secs(2u64.pow(attempt))
    }
}
}

Job Trait Methods

MethodDescriptionDefault
handle()Job execution logicRequired
name()Job identifier for loggingType name
max_retries()Retry attempts on failure3
retry_delay(attempt)Delay before retry5 seconds
timeout()Maximum execution time60 seconds
failed(error)Called when all retries exhaustedLogs error

Dispatching Jobs

Basic Dispatch

#![allow(unused)]
fn main() {
use crate::jobs::ProcessPayment;

// In a controller or service
ProcessPayment {
    order_id: 123,
    amount: 99.99,
}
.dispatch()
.await?;
}

With Delay

Process the job after a delay:

#![allow(unused)]
fn main() {
use std::time::Duration;

ProcessPayment { order_id: 123, amount: 99.99 }
    .delay(Duration::from_secs(60))  // Wait 1 minute
    .dispatch()
    .await?;
}

To Specific Queue

Route jobs to different queues for priority handling:

#![allow(unused)]
fn main() {
ProcessPayment { order_id: 123, amount: 99.99 }
    .on_queue("high-priority")
    .dispatch()
    .await?;
}

Combining Options

#![allow(unused)]
fn main() {
ProcessPayment { order_id: 123, amount: 99.99 }
    .delay(Duration::from_secs(300))  // 5 minute delay
    .on_queue("payments")
    .dispatch()
    .await?;
}

Running Workers

Development

For development, use sync mode (QUEUE_CONNECTION=sync) which processes jobs immediately during the HTTP request.

Production

Run a worker process to consume jobs from Redis:

// src/bin/worker.rs
use ferro::{Worker, WorkerConfig};
use myapp::jobs::{ProcessPayment, SendEmail, GenerateReport};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Initialize app (loads .env, connects to Redis)
    myapp::bootstrap::register().await;

    let worker = Worker::new(WorkerConfig {
        queue: "default".into(),
        ..Default::default()
    });

    // Register all job types this worker handles
    worker.register::<ProcessPayment>();
    worker.register::<SendEmail>();
    worker.register::<GenerateReport>();

    // Run forever (handles graceful shutdown)
    worker.run().await?;

    Ok(())
}

Run with:

cargo run --bin worker

Multiple Queues

Run separate workers for different queues:

# High priority worker
QUEUE_NAME=high-priority cargo run --bin worker

# Default queue worker
cargo run --bin worker

# Email-specific worker
QUEUE_NAME=emails cargo run --bin worker

Error Handling

Automatic Retries

Failed jobs are automatically retried based on max_retries() and retry_delay():

#![allow(unused)]
fn main() {
impl Job for ProcessPayment {
    fn max_retries(&self) -> u32 {
        5  // Try 5 times total
    }

    fn retry_delay(&self, attempt: u32) -> Duration {
        // Exponential backoff with jitter
        let base = Duration::from_secs(2u64.pow(attempt));
        let jitter = Duration::from_millis(rand::random::<u64>() % 1000);
        base + jitter
    }
}
}

Failed Job Handler

Handle permanent failures:

#![allow(unused)]
fn main() {
impl Job for ProcessPayment {
    async fn failed(&self, error: &Error) {
        tracing::error!(
            order_id = self.order_id,
            error = ?error,
            "Payment processing permanently failed"
        );

        // Notify admins, update order status, etc.
    }
}
}

Best Practices

  1. Keep jobs small - Jobs should do one thing well
  2. Make jobs idempotent - Safe to run multiple times
  3. Use appropriate timeouts - Set timeout() based on expected duration
  4. Handle failures gracefully - Implement failed() for cleanup
  5. Use dedicated queues - Separate critical jobs from bulk processing
  6. Monitor queue depth - Alert on growing backlogs

Environment Variables Reference

VariableDescriptionDefault
QUEUE_CONNECTION"sync" or "redis"sync
QUEUE_DEFAULTDefault queue namedefault
QUEUE_PREFIXRedis key prefixferro_queue
QUEUE_BLOCK_TIMEOUTWorker polling timeout (seconds)5
QUEUE_MAX_CONCURRENTMax parallel jobs per worker10
REDIS_URLFull Redis URL (overrides individual settings)-
REDIS_HOSTRedis server host127.0.0.1
REDIS_PORTRedis server port6379
REDIS_PASSWORDRedis password-
REDIS_DATABASERedis database number0

Notifications

Ferro provides a Laravel-inspired multi-channel notification system. Send notifications via mail, database, Slack, and more through a unified API.

Configuration

Environment Variables

Configure notifications in your .env file:

# Mail (SMTP)
MAIL_HOST=smtp.example.com
MAIL_PORT=587
MAIL_USERNAME=your-username
MAIL_PASSWORD=your-password
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="My App"
MAIL_ENCRYPTION=tls

# Slack
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/xxx/yyy/zzz

Bootstrap Setup

In src/bootstrap.rs, initialize notifications:

#![allow(unused)]
fn main() {
use ferro::{NotificationConfig, NotificationDispatcher};

pub async fn register() {
    // ... other setup ...

    // Configure notifications from environment
    let config = NotificationConfig::from_env();
    NotificationDispatcher::configure(config);
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::{NotificationConfig, MailConfig, NotificationDispatcher};

let config = NotificationConfig::new()
    .mail(
        MailConfig::new("smtp.example.com", 587, "noreply@example.com")
            .credentials("user", "pass")
            .from_name("My App")
    )
    .slack_webhook("https://hooks.slack.com/services/...");

NotificationDispatcher::configure(config);
}

Creating Notifications

Using the CLI

Generate a new notification:

ferro make:notification OrderShipped

This creates src/notifications/order_shipped.rs:

#![allow(unused)]
fn main() {
use ferro::{Notification, Channel, MailMessage};

pub struct OrderShipped {
    pub order_id: i64,
    pub tracking_number: String,
}

impl Notification for OrderShipped {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Mail]
    }

    fn to_mail(&self) -> Option<MailMessage> {
        Some(MailMessage::new()
            .subject("Your order has shipped!")
            .body(format!("Tracking: {}", self.tracking_number)))
    }
}
}

Notification Trait Methods

MethodDescriptionDefault
via()Channels to send throughRequired
to_mail()Mail message contentNone
to_database()Database message contentNone
to_slack()Slack message contentNone
notification_type()Type name for loggingType name

Making Entities Notifiable

Implement Notifiable on your User model:

#![allow(unused)]
fn main() {
use ferro::{Notifiable, Channel, async_trait};

pub struct User {
    pub id: i64,
    pub email: String,
    pub slack_webhook: Option<String>,
}

impl Notifiable for User {
    fn route_notification_for(&self, channel: Channel) -> Option<String> {
        match channel {
            Channel::Mail => Some(self.email.clone()),
            Channel::Database => Some(self.id.to_string()),
            Channel::Slack => self.slack_webhook.clone(),
            _ => None,
        }
    }

    fn notifiable_id(&self) -> String {
        self.id.to_string()
    }
}
}

Notifiable Trait Methods

MethodDescriptionDefault
route_notification_for(channel)Get routing info per channelRequired
notifiable_id()Unique identifier"unknown"
notifiable_type()Type nameType name
notify(notification)Send a notificationProvided

Sending Notifications

Basic Usage

#![allow(unused)]
fn main() {
use crate::notifications::OrderShipped;

// In a controller or service
let user = User::find(user_id).await?;
user.notify(OrderShipped {
    order_id: 123,
    tracking_number: "ABC123".into(),
}).await?;
}

Available Channels

Mail Channel

Send emails via SMTP:

#![allow(unused)]
fn main() {
impl Notification for WelcomeEmail {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Mail]
    }

    fn to_mail(&self) -> Option<MailMessage> {
        Some(MailMessage::new()
            .subject("Welcome to Our Platform")
            .body("Thanks for signing up!")
            .html("<h1>Welcome!</h1><p>Thanks for signing up!</p>")
            .cc("admin@example.com")
            .bcc("archive@example.com")
            .reply_to("support@example.com"))
    }
}
}

MailMessage Methods

MethodDescription
subject(text)Set email subject
body(text)Set plain text body
html(content)Set HTML body
from(address)Override from address
reply_to(address)Set reply-to address
cc(address)Add CC recipient
bcc(address)Add BCC recipient
header(name, value)Add custom header

Database Channel

Store notifications for in-app display:

#![allow(unused)]
fn main() {
use ferro::{Notification, Channel, DatabaseMessage};

impl Notification for OrderStatusChanged {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Database]
    }

    fn to_database(&self) -> Option<DatabaseMessage> {
        Some(DatabaseMessage::new("order_status_changed")
            .data("order_id", self.order_id)
            .data("status", &self.status)
            .data("message", format!("Order #{} is now {}", self.order_id, self.status)))
    }
}
}

DatabaseMessage Methods

MethodDescription
new(type)Create with notification type
data(key, value)Add data field
with_data(map)Add multiple fields
get(key)Get field value
to_json()Serialize to JSON

Slack Channel

Send Slack webhook notifications:

#![allow(unused)]
fn main() {
use ferro::{Notification, Channel, SlackMessage, SlackAttachment};

impl Notification for DeploymentComplete {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Slack]
    }

    fn to_slack(&self) -> Option<SlackMessage> {
        Some(SlackMessage::new("Deployment completed successfully!")
            .channel("#deployments")
            .username("Deploy Bot")
            .icon_emoji(":rocket:")
            .attachment(
                SlackAttachment::new()
                    .color("good")
                    .title("Deployment Details")
                    .field("Environment", &self.environment, true)
                    .field("Version", &self.version, true)
                    .footer("Deployed by CI/CD")
            ))
    }
}
}

SlackMessage Methods

MethodDescription
new(text)Create with main text
channel(name)Override channel
username(name)Override bot name
icon_emoji(emoji)Set emoji icon
icon_url(url)Set image icon
attachment(att)Add attachment

SlackAttachment Methods

MethodDescription
color(hex)Set sidebar color
title(text)Set attachment title
title_link(url)Make title clickable
text(content)Set attachment text
field(title, value, short)Add field
footer(text)Set footer text
timestamp(unix)Set timestamp

Multi-Channel Notifications

Send to multiple channels at once:

#![allow(unused)]
fn main() {
impl Notification for OrderPlaced {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Mail, Channel::Database, Channel::Slack]
    }

    fn to_mail(&self) -> Option<MailMessage> {
        Some(MailMessage::new()
            .subject("Order Confirmation")
            .body(format!("Order #{} placed successfully", self.order_id)))
    }

    fn to_database(&self) -> Option<DatabaseMessage> {
        Some(DatabaseMessage::new("order_placed")
            .data("order_id", self.order_id)
            .data("total", self.total))
    }

    fn to_slack(&self) -> Option<SlackMessage> {
        Some(SlackMessage::new(format!("New order #{} for ${:.2}", self.order_id, self.total)))
    }
}
}

Example: Complete Notification

#![allow(unused)]
fn main() {
// notifications/order_shipped.rs
use ferro::{Notification, Channel, MailMessage, DatabaseMessage, SlackMessage, SlackAttachment};

pub struct OrderShipped {
    pub order_id: i64,
    pub tracking_number: String,
    pub carrier: String,
    pub estimated_delivery: String,
}

impl Notification for OrderShipped {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Mail, Channel::Database, Channel::Slack]
    }

    fn to_mail(&self) -> Option<MailMessage> {
        Some(MailMessage::new()
            .subject(format!("Order #{} has shipped!", self.order_id))
            .html(format!(r#"
                <h1>Your order is on its way!</h1>
                <p>Order #{} has been shipped via {}.</p>
                <p><strong>Tracking:</strong> {}</p>
                <p><strong>Estimated Delivery:</strong> {}</p>
            "#, self.order_id, self.carrier, self.tracking_number, self.estimated_delivery)))
    }

    fn to_database(&self) -> Option<DatabaseMessage> {
        Some(DatabaseMessage::new("order_shipped")
            .data("order_id", self.order_id)
            .data("tracking_number", &self.tracking_number)
            .data("carrier", &self.carrier))
    }

    fn to_slack(&self) -> Option<SlackMessage> {
        Some(SlackMessage::new("Order shipped!")
            .attachment(
                SlackAttachment::new()
                    .color("#36a64f")
                    .title(format!("Order #{}", self.order_id))
                    .field("Carrier", &self.carrier, true)
                    .field("Tracking", &self.tracking_number, true)
                    .field("ETA", &self.estimated_delivery, false)
            ))
    }
}

// Usage in controller
let user = User::find(order.user_id).await?;
user.notify(OrderShipped {
    order_id: order.id,
    tracking_number: "1Z999AA10123456784".into(),
    carrier: "UPS".into(),
    estimated_delivery: "January 15, 2026".into(),
}).await?;
}

Environment Variables Reference

VariableDescriptionDefault
MAIL_HOSTSMTP server hostRequired
MAIL_PORTSMTP server port587
MAIL_USERNAMESMTP username-
MAIL_PASSWORDSMTP password-
MAIL_FROM_ADDRESSDefault from emailRequired
MAIL_FROM_NAMEDefault from name-
MAIL_ENCRYPTION"tls" or "none"tls
SLACK_WEBHOOK_URLSlack incoming webhook-

Best Practices

  1. Use descriptive notification names - OrderShipped not Notification1
  2. Include all needed data - Pass everything the notification needs
  3. Keep notifications focused - One notification per event
  4. Use database for in-app - Combine with UI notification center
  5. Handle failures gracefully - Log errors, don't crash on send failures
  6. Test notifications - Verify each channel works in development

Broadcasting

Ferro provides a Laravel Echo-inspired WebSocket broadcasting system for real-time communication. Push updates to clients instantly through public, private, and presence channels.

Configuration

Environment Variables

Configure broadcasting in your .env file:

# Optional limits (0 = unlimited)
BROADCAST_MAX_SUBSCRIBERS=100
BROADCAST_MAX_CHANNELS=50

# Connection settings (in seconds)
BROADCAST_HEARTBEAT_INTERVAL=30
BROADCAST_CLIENT_TIMEOUT=60

# Allow client-to-client messages
BROADCAST_ALLOW_CLIENT_EVENTS=true

Bootstrap Setup

In src/bootstrap.rs, create the broadcaster:

#![allow(unused)]
fn main() {
use ferro::{App, Broadcaster, BroadcastConfig};
use std::sync::Arc;

pub async fn register() {
    // ... other setup ...

    // Create broadcaster with environment config
    let config = BroadcastConfig::from_env();
    let broadcaster = Arc::new(Broadcaster::with_config(config));

    // Store in app state for handlers to access
    App::set_broadcaster(broadcaster);
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::{Broadcaster, BroadcastConfig};
use std::time::Duration;

let config = BroadcastConfig::new()
    .max_subscribers_per_channel(100)
    .max_channels(50)
    .heartbeat_interval(Duration::from_secs(30))
    .client_timeout(Duration::from_secs(60))
    .allow_client_events(true);

let broadcaster = Broadcaster::with_config(config);
}

Channel Types

Channels are determined by their name prefix:

TypePrefixAuthorizationUse Case
PublicnoneNoPublic updates (news feed)
Privateprivate-YesUser-specific data
Presencepresence-YesTrack online users

Examples

#![allow(unused)]
fn main() {
// Public channel - anyone can subscribe
"orders"
"notifications"

// Private channel - requires authorization
"private-orders.123"
"private-user.456"

// Presence channel - tracks members
"presence-chat.1"
"presence-room.gaming"
}

Broadcasting Messages

Basic Broadcast

#![allow(unused)]
fn main() {
use ferro::Broadcast;

// In a controller or service
let broadcast = Broadcast::new(broadcaster.clone());

broadcast
    .channel("orders.123")
    .event("OrderUpdated")
    .data(&order)
    .send()
    .await?;
}

Excluding the Sender

When a client triggers an action, exclude them from the broadcast:

#![allow(unused)]
fn main() {
broadcast
    .channel("chat.1")
    .event("NewMessage")
    .data(&message)
    .except(&socket_id)  // Don't send to this client
    .send()
    .await?;
}

Direct Broadcaster API

#![allow(unused)]
fn main() {
// Broadcast to all subscribers
broadcaster
    .broadcast("orders", "OrderCreated", &order)
    .await?;

// Broadcast excluding a client
broadcaster
    .broadcast_except("chat.1", "MessageSent", &msg, &sender_socket_id)
    .await?;
}

Channel Authorization

Private and presence channels require authorization.

Implementing an Authorizer

#![allow(unused)]
fn main() {
use ferro::{AuthData, ChannelAuthorizer, async_trait};

pub struct MyAuthorizer {
    // Database connection, etc.
}

#[async_trait]
impl ChannelAuthorizer for MyAuthorizer {
    async fn authorize(&self, data: &AuthData) -> bool {
        // data.socket_id - The client's socket ID
        // data.channel - The channel being accessed
        // data.auth_token - Optional auth token from client

        // Example: Parse user ID from channel name
        if data.channel.starts_with("private-orders.") {
            let order_id: i64 = data.channel
                .strip_prefix("private-orders.")
                .and_then(|s| s.parse().ok())
                .unwrap_or(0);

            // Verify user owns this order
            return self.user_owns_order(data.auth_token.as_deref(), order_id).await;
        }

        false
    }
}
}

Registering the Authorizer

#![allow(unused)]
fn main() {
let authorizer = MyAuthorizer::new(db_pool);
let broadcaster = Broadcaster::new().with_authorizer(authorizer);
}

Presence Channels

Presence channels track which users are online.

Subscribing with Member Info

#![allow(unused)]
fn main() {
use ferro::PresenceMember;

let member = PresenceMember::new(socket_id, user_id)
    .with_info(serde_json::json!({
        "name": user.name,
        "avatar": user.avatar_url,
    }));

broadcaster
    .subscribe(&socket_id, "presence-chat.1", Some(&auth_token), Some(member))
    .await?;
}

Member Events

When members join or leave, events are automatically broadcast:

// Member joined
{
    "type": "member_added",
    "channel": "presence-chat.1",
    "user_id": "123",
    "user_info": {"name": "Alice", "avatar": "..."}
}

// Member left
{
    "type": "member_removed",
    "channel": "presence-chat.1",
    "user_id": "123"
}

Getting Channel Members

#![allow(unused)]
fn main() {
if let Some(channel) = broadcaster.get_channel("presence-chat.1") {
    for member in channel.get_members() {
        println!("User {} is online", member.user_id);
    }
}
}

Message Types

Server to Client

#![allow(unused)]
fn main() {
pub enum ServerMessage {
    // Connection established
    Connected { socket_id: String },

    // Subscription confirmed
    Subscribed { channel: String },

    // Subscription failed
    SubscriptionError { channel: String, error: String },

    // Unsubscribed
    Unsubscribed { channel: String },

    // Broadcast event
    Event(BroadcastMessage),

    // Presence: member joined
    MemberAdded { channel: String, user_id: String, user_info: Value },

    // Presence: member left
    MemberRemoved { channel: String, user_id: String },

    // Keepalive response
    Pong,

    // Error
    Error { message: String },
}
}

Client to Server

#![allow(unused)]
fn main() {
pub enum ClientMessage {
    // Subscribe to channel
    Subscribe { channel: String, auth: Option<String> },

    // Unsubscribe from channel
    Unsubscribe { channel: String },

    // Client event (whisper)
    Whisper { channel: String, event: String, data: Value },

    // Keepalive
    Ping,
}
}

WebSocket Handler Example

#![allow(unused)]
fn main() {
use ferro::{Broadcaster, ClientMessage, ServerMessage};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;

async fn handle_websocket(
    ws_stream: WebSocketStream,
    broadcaster: Arc<Broadcaster>,
) {
    let socket_id = generate_socket_id();
    let (tx, mut rx) = mpsc::channel::<ServerMessage>(32);

    // Register client
    broadcaster.add_client(socket_id.clone(), tx);

    // Send connection confirmation
    let connected = ServerMessage::Connected {
        socket_id: socket_id.clone()
    };
    // ... send to client

    // Handle messages
    loop {
        tokio::select! {
            // Message from client
            Some(msg) = ws_stream.next() => {
                match parse_client_message(msg) {
                    ClientMessage::Subscribe { channel, auth } => {
                        match broadcaster.subscribe(&socket_id, &channel, auth.as_deref(), None).await {
                            Ok(_) => { /* send Subscribed */ }
                            Err(e) => { /* send SubscriptionError */ }
                        }
                    }
                    ClientMessage::Unsubscribe { channel } => {
                        broadcaster.unsubscribe(&socket_id, &channel).await;
                    }
                    ClientMessage::Ping => {
                        // send Pong
                    }
                    _ => {}
                }
            }

            // Message to send to client
            Some(msg) = rx.recv() => {
                // Send to WebSocket
            }
        }
    }

    // Cleanup on disconnect
    broadcaster.remove_client(&socket_id);
}
}

Example: Real-time Chat

#![allow(unused)]
fn main() {
// When a message is sent
async fn send_message(
    broadcaster: Arc<Broadcaster>,
    room_id: i64,
    user: &User,
    content: String,
    socket_id: &str,
) -> Result<Message, Error> {
    // Save to database
    let message = Message::create(room_id, user.id, &content).await?;

    // Broadcast to room (except sender)
    Broadcast::new(broadcaster)
        .channel(&format!("presence-chat.{}", room_id))
        .event("NewMessage")
        .data(serde_json::json!({
            "id": message.id,
            "user": {
                "id": user.id,
                "name": user.name,
            },
            "content": content,
            "created_at": message.created_at,
        }))
        .except(socket_id)
        .send()
        .await?;

    Ok(message)
}
}

Monitoring

#![allow(unused)]
fn main() {
// Get connection stats
let client_count = broadcaster.client_count();
let channel_count = broadcaster.channel_count();

// Get specific channel info
if let Some(channel) = broadcaster.get_channel("orders") {
    println!("Subscribers: {}", channel.subscriber_count());
}
}

Environment Variables Reference

VariableDescriptionDefault
BROADCAST_MAX_SUBSCRIBERSMax subscribers per channel (0=unlimited)0
BROADCAST_MAX_CHANNELSMax total channels (0=unlimited)0
BROADCAST_HEARTBEAT_INTERVALHeartbeat interval (seconds)30
BROADCAST_CLIENT_TIMEOUTClient timeout (seconds)60
BROADCAST_ALLOW_CLIENT_EVENTSAllow whisper messagestrue

Best Practices

  1. Use meaningful channel names - orders.{id} not channel1
  2. Authorize private data - Always use private channels for user-specific data
  3. Use presence for online status - Track who's viewing/editing
  4. Exclude senders when appropriate - Avoid echo effects
  5. Set reasonable limits - Prevent resource exhaustion with config limits
  6. Handle disconnects gracefully - Clean up subscriptions on disconnect

Storage

Ferro provides a unified file storage abstraction inspired by Laravel's filesystem. Work with local files, memory storage, and cloud providers through a consistent API.

Configuration

Environment Variables

Configure storage in your .env file:

# Default disk (local, public, or s3)
FILESYSTEM_DISK=local

# Local disk settings
FILESYSTEM_LOCAL_ROOT=./storage
FILESYSTEM_LOCAL_URL=

# Public disk settings (for web-accessible files)
FILESYSTEM_PUBLIC_ROOT=./storage/public
FILESYSTEM_PUBLIC_URL=/storage

# S3 disk settings (requires s3 feature)
AWS_ACCESS_KEY_ID=your-key
AWS_SECRET_ACCESS_KEY=your-secret
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket
AWS_URL=https://your-bucket.s3.amazonaws.com

Bootstrap Setup

In src/bootstrap.rs, configure storage:

#![allow(unused)]
fn main() {
use ferro::{App, Storage, StorageConfig};
use std::sync::Arc;

pub async fn register() {
    // ... other setup ...

    // Create storage with environment config
    let config = StorageConfig::from_env();
    let storage = Arc::new(Storage::with_storage_config(config));

    // Store in app state for handlers to access
    App::set_storage(storage);
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::{Storage, StorageConfig, DiskConfig};

let config = StorageConfig::new("local")
    .disk("local", DiskConfig::local("./storage"))
    .disk("public", DiskConfig::local("./storage/public").with_url("/storage"))
    .disk("uploads", DiskConfig::local("./uploads").with_url("/uploads"));

let storage = Storage::with_storage_config(config);
}

Basic Usage

Storing Files

#![allow(unused)]
fn main() {
use ferro::Storage;

// Store string content
storage.put("documents/report.txt", "Report content").await?;

// Store bytes
storage.put("images/photo.jpg", image_bytes).await?;

// Store with visibility options
use ferro::PutOptions;

storage.put_with_options(
    "private/secret.txt",
    "secret content",
    PutOptions::new().visibility(Visibility::Private),
).await?;
}

Retrieving Files

#![allow(unused)]
fn main() {
// Get as bytes
let contents = storage.get("documents/report.txt").await?;

// Get as string
let text = storage.get_string("documents/report.txt").await?;

// Check if file exists
if storage.exists("documents/report.txt").await? {
    println!("File exists!");
}
}

Deleting Files

#![allow(unused)]
fn main() {
// Delete a single file
storage.delete("temp/cache.txt").await?;

// Delete a directory and all contents
storage.disk("local")?.delete_directory("temp").await?;
}

Copying and Moving

#![allow(unused)]
fn main() {
// Copy a file
storage.copy("original.txt", "backup/original.txt").await?;

// Move/rename a file
storage.rename("old-name.txt", "new-name.txt").await?;
}

Multiple Disks

Switching Disks

#![allow(unused)]
fn main() {
// Use the default disk
storage.put("file.txt", "content").await?;

// Use a specific disk
let public_disk = storage.disk("public")?;
public_disk.put("images/logo.png", logo_bytes).await?;

// Get file from specific disk
let file = storage.disk("uploads")?.get("user-upload.pdf").await?;
}

Disk Configuration

Each disk is configured independently:

#![allow(unused)]
fn main() {
use ferro::{StorageConfig, DiskConfig};

let config = StorageConfig::new("local")
    // Main storage disk
    .disk("local", DiskConfig::local("./storage/app"))
    // Publicly accessible files
    .disk("public", DiskConfig::local("./storage/public").with_url("/storage"))
    // Temporary files
    .disk("temp", DiskConfig::local("/tmp/app"))
    // Memory disk for testing
    .disk("testing", DiskConfig::memory());
}

File URLs

Public URLs

#![allow(unused)]
fn main() {
// Get the public URL for a file
let url = storage.disk("public")?.url("images/logo.png").await?;
// Returns: /storage/images/logo.png

// With a custom URL base
let config = DiskConfig::local("./uploads")
    .with_url("https://cdn.example.com/uploads");
// url() returns: https://cdn.example.com/uploads/images/logo.png
}

Temporary URLs

For files that need time-limited access:

#![allow(unused)]
fn main() {
use std::time::Duration;

// Get a temporary URL (useful for S3 presigned URLs)
let disk = storage.disk("s3")?;
let temp_url = disk.temporary_url(
    "private/document.pdf",
    Duration::from_secs(3600), // 1 hour
).await?;
}

File Information

Metadata

#![allow(unused)]
fn main() {
let disk = storage.disk("local")?;

// Get file size
let size = disk.size("document.pdf").await?;
println!("File size: {} bytes", size);

// Get full metadata
let metadata = disk.metadata("document.pdf").await?;
println!("Path: {}", metadata.path);
println!("Size: {}", metadata.size);
println!("MIME type: {:?}", metadata.mime_type);
println!("Last modified: {:?}", metadata.last_modified);
}

Visibility

#![allow(unused)]
fn main() {
use ferro::{PutOptions, Visibility};

// Store with private visibility
storage.put_with_options(
    "private/data.json",
    json_data,
    PutOptions::new().visibility(Visibility::Private),
).await?;

// Store with public visibility
storage.put_with_options(
    "public/image.jpg",
    image_data,
    PutOptions::new().visibility(Visibility::Public),
).await?;
}

Directory Operations

Listing Files

#![allow(unused)]
fn main() {
let disk = storage.disk("local")?;

// List files in a directory (non-recursive)
let files = disk.files("documents").await?;
for file in files {
    println!("File: {}", file);
}

// List all files recursively
let all_files = disk.all_files("documents").await?;
for file in all_files {
    println!("File: {}", file);
}

// List directories
let dirs = disk.directories("documents").await?;
for dir in dirs {
    println!("Directory: {}", dir);
}
}

Creating Directories

#![allow(unused)]
fn main() {
let disk = storage.disk("local")?;

// Create a directory
disk.make_directory("uploads/2024/01").await?;

// Delete a directory and contents
disk.delete_directory("temp").await?;
}

Available Drivers

Local Driver

Stores files on the local filesystem:

#![allow(unused)]
fn main() {
let config = DiskConfig::local("./storage")
    .with_url("https://example.com/storage");
}

Memory Driver

Stores files in memory (useful for testing):

#![allow(unused)]
fn main() {
let config = DiskConfig::memory()
    .with_url("https://cdn.example.com");
}

S3 Driver

Requires the s3 feature (coming soon):

[dependencies]
ferro = { version = "0.1", features = ["s3"] }

Example: File Upload Handler

#![allow(unused)]
fn main() {
use ferro::{Request, Response, Storage};
use std::sync::Arc;

async fn upload_file(
    request: Request,
    storage: Arc<Storage>,
) -> Response {
    // Get uploaded file from multipart form
    let file = request.file("document")?;

    // Generate unique filename
    let filename = format!(
        "uploads/{}/{}",
        chrono::Utc::now().format("%Y/%m/%d"),
        file.name()
    );

    // Store the file
    storage.disk("public")?
        .put(&filename, file.bytes())
        .await?;

    // Get the public URL
    let url = storage.disk("public")?
        .url(&filename)
        .await?;

    Response::json(&serde_json::json!({
        "success": true,
        "url": url,
    }))
}
}

Example: Avatar Upload with Validation

#![allow(unused)]
fn main() {
use ferro::{Request, Response, Storage, PutOptions, Visibility};
use std::sync::Arc;

async fn upload_avatar(
    request: Request,
    storage: Arc<Storage>,
    user_id: i64,
) -> Response {
    let file = request.file("avatar")?;

    // Validate file type
    let allowed_types = ["image/jpeg", "image/png", "image/webp"];
    if !allowed_types.contains(&file.content_type()) {
        return Response::bad_request("Invalid file type");
    }

    // Validate file size (max 5MB)
    if file.size() > 5 * 1024 * 1024 {
        return Response::bad_request("File too large");
    }

    // Delete old avatar if exists
    let old_path = format!("avatars/{}.jpg", user_id);
    if storage.exists(&old_path).await? {
        storage.delete(&old_path).await?;
    }

    // Store new avatar
    let path = format!("avatars/{}.{}", user_id, file.extension());
    storage.disk("public")?
        .put_with_options(
            &path,
            file.bytes(),
            PutOptions::new().visibility(Visibility::Public),
        )
        .await?;

    let url = storage.disk("public")?.url(&path).await?;

    Response::json(&serde_json::json!({
        "avatar_url": url,
    }))
}
}

Environment Variables Reference

VariableDescriptionDefault
FILESYSTEM_DISKDefault disk namelocal
FILESYSTEM_LOCAL_ROOTLocal disk root path./storage
FILESYSTEM_LOCAL_URLLocal disk URL base-
FILESYSTEM_PUBLIC_ROOTPublic disk root path./storage/public
FILESYSTEM_PUBLIC_URLPublic disk URL base/storage
AWS_ACCESS_KEY_IDS3 access key-
AWS_SECRET_ACCESS_KEYS3 secret key-
AWS_DEFAULT_REGIONS3 regionus-east-1
AWS_BUCKETS3 bucket name-
AWS_URLS3 URL base-

Best Practices

  1. Use meaningful disk names - public, uploads, backups instead of disk1
  2. Set appropriate visibility - Use private for sensitive files
  3. Organize files by date - uploads/2024/01/file.pdf prevents directory bloat
  4. Use the public disk for web assets - Images, CSS, JS that need URLs
  5. Use memory driver for tests - Fast and isolated testing
  6. Clean up temporary files - Delete files that are no longer needed
  7. Validate uploads - Check file types and sizes before storing

Caching

Ferro provides a unified caching API with support for multiple backends, cache tags for bulk invalidation, and the convenient "remember" pattern for lazy caching.

Configuration

Environment Variables

Configure caching in your .env file:

# Cache driver (memory or redis)
CACHE_DRIVER=memory

# Key prefix for all cache entries
CACHE_PREFIX=myapp

# Default TTL in seconds
CACHE_TTL=3600

# Memory store capacity (max entries)
CACHE_MEMORY_CAPACITY=10000

# Redis URL (required if CACHE_DRIVER=redis)
REDIS_URL=redis://127.0.0.1:6379

Bootstrap Setup

In src/bootstrap.rs, configure caching:

#![allow(unused)]
fn main() {
use ferro::{App, Cache};
use std::sync::Arc;

pub async fn register() {
    // ... other setup ...

    // Create cache from environment variables
    let cache = Arc::new(Cache::from_env().await?);

    // Store in app state for handlers to access
    App::set_cache(cache);
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::{Cache, CacheConfig};
use std::time::Duration;

// In-memory cache with custom config
let config = CacheConfig::new()
    .with_ttl(Duration::from_secs(1800))
    .with_prefix("myapp");

let cache = Cache::memory().with_config(config);

// Redis cache
let cache = Cache::redis("redis://127.0.0.1:6379").await?;
}

Basic Usage

Storing Values

#![allow(unused)]
fn main() {
use std::time::Duration;

// Store a value with specific TTL
cache.put("user:1", &user, Duration::from_secs(3600)).await?;

// Store with default TTL
cache.put_default("user:1", &user).await?;

// Store forever (10 years TTL)
cache.forever("config:settings", &settings).await?;
}

Retrieving Values

#![allow(unused)]
fn main() {
// Get a value
let user: Option<User> = cache.get("user:1").await?;

if let Some(user) = user {
    println!("Found user: {}", user.name);
}

// Check if key exists
if cache.has("user:1").await? {
    println!("User is cached");
}
}

Removing Values

#![allow(unused)]
fn main() {
// Remove a single key
cache.forget("user:1").await?;

// Remove all cached values
cache.flush().await?;
}

Pull (Get and Remove)

#![allow(unused)]
fn main() {
// Get value and remove it from cache
let session: Option<Session> = cache.pull("session:abc123").await?;
}

Remember Pattern

The remember pattern retrieves a cached value or computes and caches it if missing:

#![allow(unused)]
fn main() {
use std::time::Duration;

// Get from cache or compute if missing
let users = cache.remember("users:active", Duration::from_secs(3600), || async {
    // This only runs if "users:active" is not in cache
    User::where_active().all().await
}).await?;

// Remember forever
let config = cache.remember_forever("app:config", || async {
    load_config_from_database().await
}).await?;
}

This pattern is excellent for:

  • Database query results
  • API responses
  • Expensive computations
  • Configuration that rarely changes

Cache Tags

Tags allow you to group related cache entries for bulk invalidation.

Storing with Tags

#![allow(unused)]
fn main() {
use std::time::Duration;

// Store with a single tag
cache.tags(&["users"])
    .put("user:1", &user, Duration::from_secs(3600))
    .await?;

// Store with multiple tags
cache.tags(&["users", "admins"])
    .put("admin:1", &admin, Duration::from_secs(3600))
    .await?;

// Remember with tags
let user = cache.tags(&["users"])
    .remember("user:1", Duration::from_secs(3600), || async {
        User::find(1).await
    })
    .await?;
}

Flushing Tags

#![allow(unused)]
fn main() {
// Flush all entries tagged with "users"
cache.tags(&["users"]).flush().await?;

// This removes:
// - "user:1" (tagged with ["users"])
// - "admin:1" (tagged with ["users", "admins"])
}

Tag Use Cases

#![allow(unused)]
fn main() {
// Cache user data
cache.tags(&["users", &format!("user:{}", user.id)])
    .put(&format!("user:{}", user.id), &user, ttl)
    .await?;

// Cache user's posts
cache.tags(&["posts", &format!("user:{}:posts", user.id)])
    .put(&format!("user:{}:posts", user.id), &posts, ttl)
    .await?;

// When user is updated, flush their cache
cache.tags(&[&format!("user:{}", user.id)]).flush().await?;

// When any user data changes, flush all user cache
cache.tags(&["users"]).flush().await?;
}

Atomic Operations

Increment and Decrement

#![allow(unused)]
fn main() {
// Increment a counter
let views = cache.increment("page:views", 1).await?;
println!("Page has {} views", views);

// Increment by more than 1
let score = cache.increment("player:score", 100).await?;

// Decrement
let stock = cache.decrement("product:stock", 1).await?;
}

Cache Backends

Memory Store

Fast in-memory caching using moka. Best for:

  • Single-server deployments
  • Development/testing
  • Non-critical cache data
#![allow(unused)]
fn main() {
// Default capacity (10,000 entries)
let cache = Cache::memory();

// Custom capacity
let store = MemoryStore::with_capacity(50_000);
let cache = Cache::new(Arc::new(store));
}

Redis Store

Distributed caching with Redis. Best for:

  • Multi-server deployments
  • Persistent cache (survives restarts)
  • Shared cache across services
#![allow(unused)]
fn main() {
let cache = Cache::redis("redis://127.0.0.1:6379").await?;

// With authentication
let cache = Cache::redis("redis://:password@127.0.0.1:6379").await?;

// With database selection
let cache = Cache::redis("redis://127.0.0.1:6379/2").await?;
}

Enable the Redis backend in Cargo.toml:

[dependencies]
ferro = { version = "0.1", features = ["redis-backend"] }

Example: API Response Caching

#![allow(unused)]
fn main() {
use ferro::{Request, Response, Cache};
use std::sync::Arc;
use std::time::Duration;

async fn get_products(
    request: Request,
    cache: Arc<Cache>,
) -> Response {
    let category = request.param("category")?;

    // Cache key based on category
    let cache_key = format!("products:category:{}", category);

    // Get from cache or fetch from database
    let products = cache.remember(&cache_key, Duration::from_secs(300), || async {
        Product::where_category(&category).all().await
    }).await?;

    Response::json(&products)
}
}

Example: User Session Caching

#![allow(unused)]
fn main() {
use ferro::Cache;
use std::sync::Arc;
use std::time::Duration;

async fn cache_user_session(
    cache: Arc<Cache>,
    user_id: i64,
    session: &UserSession,
) -> Result<(), Error> {
    // Cache with user-specific tag for easy invalidation
    cache.tags(&["sessions", &format!("user:{}", user_id)])
        .put(
            &format!("session:{}", session.id),
            session,
            Duration::from_secs(86400), // 24 hours
        )
        .await
}

async fn invalidate_user_sessions(
    cache: Arc<Cache>,
    user_id: i64,
) -> Result<(), Error> {
    // Flush all sessions for this user
    cache.tags(&[&format!("user:{}", user_id)]).flush().await
}
}

Example: Rate Limiting with Cache

#![allow(unused)]
fn main() {
use ferro::Cache;
use std::sync::Arc;

async fn check_rate_limit(
    cache: Arc<Cache>,
    user_id: i64,
    limit: i64,
) -> Result<bool, Error> {
    let key = format!("rate_limit:user:{}", user_id);

    // Increment the counter
    let count = cache.increment(&key, 1).await?;

    // Set TTL on first request (1 minute window)
    if count == 1 {
        // Note: For production, use Redis SETEX or similar
        // This is a simplified example
    }

    Ok(count <= limit)
}
}

Environment Variables Reference

VariableDescriptionDefault
CACHE_DRIVERCache backend ("memory" or "redis")memory
CACHE_PREFIXKey prefix for all entries-
CACHE_TTLDefault TTL in seconds3600
CACHE_MEMORY_CAPACITYMax entries for memory store10000
REDIS_URLRedis connection URLredis://127.0.0.1:6379

Best Practices

  1. Use meaningful cache keys - user:123:profile not key1
  2. Set appropriate TTLs - Balance freshness vs performance
  3. Use tags for related data - Makes invalidation easier
  4. Cache at the right level - Cache complete objects, not fragments
  5. Handle cache misses gracefully - Always have a fallback
  6. Use remember pattern - Cleaner code, less boilerplate
  7. Prefix keys in production - Avoid collisions between environments
  8. Monitor cache hit rates - Identify optimization opportunities

Database

Ferro provides a SeaORM-based database layer with Laravel-like API, automatic route model binding, fluent query builder, and testing utilities.

Configuration

Environment Variables

Configure database in your .env file:

# Database connection URL (required)
DATABASE_URL=postgres://user:pass@localhost:5432/mydb

# For SQLite:
# DATABASE_URL=sqlite://./database.db

# Connection pool settings
DB_MAX_CONNECTIONS=10
DB_MIN_CONNECTIONS=1
DB_CONNECT_TIMEOUT=30

# Enable SQL query logging
DB_LOGGING=false

Bootstrap Setup

In src/bootstrap.rs, configure the database:

#![allow(unused)]
fn main() {
use ferro::{Config, DB, DatabaseConfig};

pub async fn register() {
    // Register database configuration
    Config::register(DatabaseConfig::from_env());

    // Initialize the database connection
    DB::init().await.expect("Failed to connect to database");
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::{Config, DB, DatabaseConfig};

// Build configuration programmatically
let config = DatabaseConfig::builder()
    .url("postgres://localhost/mydb")
    .max_connections(20)
    .min_connections(5)
    .connect_timeout(60)
    .logging(true)
    .build();

// Initialize with custom config
DB::init_with(config).await?;
}

Basic Usage

Getting a Connection

#![allow(unused)]
fn main() {
use ferro::DB;

// Get the database connection
let conn = DB::connection()?;

// Use with SeaORM queries
let users = User::find().all(conn.inner()).await?;

// Shorthand
let conn = DB::get()?;
}

Checking Connection Status

#![allow(unused)]
fn main() {
use ferro::DB;

if DB::is_connected() {
    let conn = DB::connection()?;
    // Perform database operations
}
}

Models

Ferro provides Laravel-like traits for SeaORM entities.

Defining a Model

#![allow(unused)]
fn main() {
use sea_orm::entity::prelude::*;
use ferro::database::{Model, ModelMut};

#[derive(Clone, Debug, DeriveEntityModel)]
#[sea_orm(table_name = "users")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub name: String,
    pub email: String,
    pub created_at: DateTime,
    pub updated_at: DateTime,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}

// Add Ferro's Model traits for convenient methods
impl ferro::database::Model for Entity {}
impl ferro::database::ModelMut for Entity {}
}

Reading Records

#![allow(unused)]
fn main() {
use ferro::models::user;

// Find all records
let users = user::Entity::all().await?;

// Find by primary key
let user = user::Entity::find_by_pk(1).await?;

// Find or return error
let user = user::Entity::find_or_fail(1).await?;

// Get first record
let first = user::Entity::first().await?;

// Count all records
let count = user::Entity::count_all().await?;

// Check if any exist
if user::Entity::exists_any().await? {
    println!("Users exist!");
}
}

Creating Records

#![allow(unused)]
fn main() {
use sea_orm::Set;
use ferro::models::user;

let new_user = user::ActiveModel {
    name: Set("John Doe".to_string()),
    email: Set("john@example.com".to_string()),
    ..Default::default()
};

let user = user::Entity::insert_one(new_user).await?;
println!("Created user with id: {}", user.id);
}

Updating Records

#![allow(unused)]
fn main() {
use sea_orm::Set;
use ferro::models::user;

// Find and update
let user = user::Entity::find_or_fail(1).await?;
let mut active: user::ActiveModel = user.into();
active.name = Set("Updated Name".to_string());

let updated = user::Entity::update_one(active).await?;
}

Deleting Records

#![allow(unused)]
fn main() {
use ferro::models::user;

// Delete by primary key
let rows_deleted = user::Entity::delete_by_pk(1).await?;
println!("Deleted {} rows", rows_deleted);
}

Save (Insert or Update)

#![allow(unused)]
fn main() {
use sea_orm::Set;
use ferro::models::user;

// Save will insert if no primary key, update if present
let model = user::ActiveModel {
    name: Set("Jane Doe".to_string()),
    email: Set("jane@example.com".to_string()),
    ..Default::default()
};

let saved = user::Entity::save_one(model).await?;
}

Query Builder

The fluent query builder provides an Eloquent-like API.

Basic Queries

#![allow(unused)]
fn main() {
use ferro::models::todo::{self, Column};

// Get all records
let todos = todo::Entity::query().all().await?;

// Get first record
let todo = todo::Entity::query().first().await?;

// Get first or error
let todo = todo::Entity::query().first_or_fail().await?;
}

Filtering

#![allow(unused)]
fn main() {
use ferro::models::todo::{self, Column};

// Single filter
let todos = todo::Entity::query()
    .filter(Column::Active.eq(true))
    .all()
    .await?;

// Multiple filters (AND)
let todo = todo::Entity::query()
    .filter(Column::Title.eq("My Task"))
    .filter(Column::Id.gt(5))
    .first()
    .await?;

// Using SeaORM conditions
use sea_orm::Condition;

let todos = todo::Entity::query()
    .filter(
        Condition::any()
            .add(Column::Priority.eq("high"))
            .add(Column::DueDate.lt(chrono::Utc::now()))
    )
    .all()
    .await?;
}

Ordering

#![allow(unused)]
fn main() {
use ferro::models::todo::{self, Column};

// Order ascending
let todos = todo::Entity::query()
    .order_by_asc(Column::Title)
    .all()
    .await?;

// Order descending
let todos = todo::Entity::query()
    .order_by_desc(Column::CreatedAt)
    .all()
    .await?;

// Multiple orderings
let todos = todo::Entity::query()
    .order_by_desc(Column::Priority)
    .order_by_asc(Column::Title)
    .all()
    .await?;
}

Pagination

#![allow(unused)]
fn main() {
use ferro::models::todo;

// Limit results
let todos = todo::Entity::query()
    .limit(10)
    .all()
    .await?;

// Offset and limit (pagination)
let page = 2;
let per_page = 10;

let todos = todo::Entity::query()
    .offset((page - 1) * per_page)
    .limit(per_page)
    .all()
    .await?;
}

Counting and Existence

#![allow(unused)]
fn main() {
use ferro::models::todo::{self, Column};

// Count matching records
let count = todo::Entity::query()
    .filter(Column::Active.eq(true))
    .count()
    .await?;

// Check if any exist
let has_active = todo::Entity::query()
    .filter(Column::Active.eq(true))
    .exists()
    .await?;
}

Advanced Queries

#![allow(unused)]
fn main() {
use ferro::models::todo;

// Get underlying SeaORM Select for advanced operations
let select = todo::Entity::query()
    .filter(Column::Active.eq(true))
    .into_select();

// Use with SeaORM directly
let conn = DB::connection()?;
let todos = select
    .join(JoinType::LeftJoin, todo::Relation::User.def())
    .all(conn.inner())
    .await?;
}

Route Model Binding

Ferro automatically resolves models from route parameters.

Automatic Binding

#![allow(unused)]
fn main() {
use ferro::{handler, json_response, Response};
use ferro::models::user;

// The 'user' parameter is automatically resolved from the route
#[handler]
pub async fn show(user: user::Model) -> Response {
    json_response!({ "name": user.name, "email": user.email })
}

// Route definition: get!("/users/{user}", controllers::user::show)
// The {user} parameter is parsed as the primary key and the model is fetched
}

How It Works

  1. Route parameter {user} is extracted from the URL
  2. The parameter value is parsed as the model's primary key type
  3. The model is fetched from the database
  4. If not found, a 404 response is returned automatically
  5. If the parameter can't be parsed, a 400 response is returned

Custom Route Binding

For custom binding logic, implement the RouteBinding trait:

#![allow(unused)]
fn main() {
use ferro::database::RouteBinding;
use ferro::FrameworkError;
use async_trait::async_trait;

#[async_trait]
impl RouteBinding for user::Model {
    fn param_name() -> &'static str {
        "user"
    }

    async fn from_route_param(value: &str) -> Result<Self, FrameworkError> {
        // Custom logic: find by email instead of ID
        let conn = DB::connection()?;
        user::Entity::find()
            .filter(user::Column::Email.eq(value))
            .one(conn.inner())
            .await?
            .ok_or_else(|| FrameworkError::model_not_found("User"))
    }
}
}

Migrations

Ferro uses SeaORM migrations with a timestamp-based naming convention.

Creating Migrations

# Create a new migration
ferro make:migration create_posts_table

# Creates: src/migrations/m20240115_143052_create_posts_table.rs

Migration Structure

#![allow(unused)]
fn main() {
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .create_table(
                Table::create()
                    .table(Posts::Table)
                    .if_not_exists()
                    .col(
                        ColumnDef::new(Posts::Id)
                            .integer()
                            .not_null()
                            .auto_increment()
                            .primary_key(),
                    )
                    .col(ColumnDef::new(Posts::Title).string().not_null())
                    .col(ColumnDef::new(Posts::Content).text().not_null())
                    .col(
                        ColumnDef::new(Posts::CreatedAt)
                            .timestamp()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .col(
                        ColumnDef::new(Posts::UpdatedAt)
                            .timestamp()
                            .not_null()
                            .default(Expr::current_timestamp()),
                    )
                    .to_owned(),
            )
            .await
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        manager
            .drop_table(Table::drop().table(Posts::Table).to_owned())
            .await
    }
}

#[derive(DeriveIden)]
enum Posts {
    Table,
    Id,
    Title,
    Content,
    CreatedAt,
    UpdatedAt,
}
}

Running Migrations

# Run all pending migrations
ferro migrate

# Rollback the last batch
ferro migrate:rollback

# Rollback all and re-run
ferro migrate:fresh

# Check migration status
ferro migrate:status

Testing

Ferro provides utilities for isolated database testing.

Test Database

#![allow(unused)]
fn main() {
use ferro::test_database;
use ferro::models::user;

#[tokio::test]
async fn test_create_user() {
    // Creates fresh in-memory SQLite with migrations
    let db = test_database!();

    // Your test code - DB::connection() automatically uses test database
    let new_user = user::ActiveModel {
        name: Set("Test User".to_string()),
        email: Set("test@example.com".to_string()),
        ..Default::default()
    };

    let user = user::Entity::insert_one(new_user).await.unwrap();
    assert!(user.id > 0);

    // Query directly
    let found = user::Entity::find_by_id(user.id)
        .one(db.conn())
        .await
        .unwrap();
    assert!(found.is_some());
}
}

Custom Migrator

#![allow(unused)]
fn main() {
use ferro::testing::TestDatabase;

#[tokio::test]
async fn test_with_custom_migrator() {
    let db = TestDatabase::fresh::<my_crate::CustomMigrator>()
        .await
        .unwrap();

    // Test code here
}
}

Isolation

Each TestDatabase creates a completely isolated in-memory database:

  • Fresh database for each test
  • Migrations are run automatically
  • No interference between tests
  • Automatically cleaned up when dropped

Dependency Injection

Use the Database type alias with dependency injection:

#![allow(unused)]
fn main() {
use ferro::{injectable, Database};

#[injectable]
pub struct CreateUserAction {
    #[inject]
    db: Database,
}

impl CreateUserAction {
    pub async fn execute(&self, email: &str) -> Result<user::Model, Error> {
        let new_user = user::ActiveModel {
            email: Set(email.to_string()),
            ..Default::default()
        };

        new_user.insert(self.db.conn()).await
    }
}
}

Environment Variables Reference

VariableDescriptionDefault
DATABASE_URLDatabase connection URLsqlite://./database.db
DB_MAX_CONNECTIONSMaximum pool connections10
DB_MIN_CONNECTIONSMinimum pool connections1
DB_CONNECT_TIMEOUTConnection timeout (seconds)30
DB_LOGGINGEnable SQL query loggingfalse

Supported Databases

DatabaseURL FormatNotes
PostgreSQLpostgres://user:pass@host:5432/dbRecommended for production
SQLitesqlite://./path/to/db.sqliteGreat for development
SQLite (memory)sqlite::memory:For testing

Best Practices

  1. Use migrations - Never modify database schema manually
  2. Implement Model traits - Get convenient static methods for free
  3. Use QueryBuilder - Cleaner API than raw SeaORM queries
  4. Leverage route binding - Automatic 404 handling for missing models
  5. Test with test_database! - Isolated, repeatable tests
  6. Use dependency injection - Cleaner code with #[inject] db: Database
  7. Enable logging in development - DB_LOGGING=true for debugging
  8. Set appropriate pool sizes - Match your expected concurrency

Validation

Ferro provides a powerful validation system with a fluent API, built-in rules, custom messages, and automatic request validation through Form Requests.

Basic Usage

Creating a Validator

#![allow(unused)]
fn main() {
use ferro::validation::{Validator, rules};

let data = serde_json::json!({
    "name": "John Doe",
    "email": "john@example.com",
    "age": 25
});

let errors = Validator::new()
    .rule("name", rules![required(), string(), min(2)])
    .rule("email", rules![required(), email()])
    .rule("age", rules![required(), integer(), between(18, 120)])
    .validate(&data);

if errors.is_empty() {
    println!("Validation passed!");
} else {
    println!("Errors: {:?}", errors.all());
}
}

Quick Validation

#![allow(unused)]
fn main() {
use ferro::validation::{validate, rules};

let data = serde_json::json!({
    "email": "invalid-email"
});

let errors = validate(&data, vec![
    ("email", rules![required(), email()]),
]);

if errors.fails() {
    for (field, messages) in errors.all() {
        println!("{}: {:?}", field, messages);
    }
}
}

Built-in Rules

Required Rules

#![allow(unused)]
fn main() {
use ferro::validation::rules::*;

// Field must be present and not empty
required()

// Field required only if another field has a specific value
required_if("role", "admin")
}

Type Rules

#![allow(unused)]
fn main() {
// Must be a string
string()

// Must be an integer
integer()

// Must be numeric (integer or float)
numeric()

// Must be a boolean
boolean()

// Must be an array
array()
}

Size Rules

#![allow(unused)]
fn main() {
// Minimum length (strings) or value (numbers)
min(5)

// Maximum length (strings) or value (numbers)
max(100)

// Between minimum and maximum (inclusive)
between(1, 10)
}

Format Rules

#![allow(unused)]
fn main() {
// Valid email address
email()

// Valid URL
url()

// Matches a regex pattern
regex(r"^[A-Z]{2}\d{4}$")

// Only alphabetic characters
alpha()

// Only alphanumeric characters
alpha_num()

// Alphanumeric, dashes, and underscores
alpha_dash()

// Valid date (YYYY-MM-DD format)
date()
}

Comparison Rules

#![allow(unused)]
fn main() {
// Must match {field}_confirmation
confirmed()

// Must be one of the specified values
in_array(vec!["active", "inactive", "pending"])

// Must NOT be one of the specified values
not_in(vec!["admin", "root"])

// Must be different from another field
different("old_password")

// Must be the same as another field
same("password")
}

Special Rules

#![allow(unused)]
fn main() {
// Field can be null/missing (stops validation if null)
nullable()

// Must be "yes", "on", "1", or true
accepted()
}

Validation Examples

User Registration

#![allow(unused)]
fn main() {
use ferro::validation::{Validator, rules};

let data = serde_json::json!({
    "username": "johndoe",
    "email": "john@example.com",
    "password": "secret123",
    "password_confirmation": "secret123",
    "age": 25,
    "terms": "yes"
});

let errors = Validator::new()
    .rule("username", rules![required(), string(), min(3), max(20), alpha_dash()])
    .rule("email", rules![required(), email()])
    .rule("password", rules![required(), string(), min(8), confirmed()])
    .rule("age", rules![required(), integer(), between(13, 120)])
    .rule("terms", rules![accepted()])
    .validate(&data);
}

Nested Data Validation

Use dot notation to validate nested JSON structures:

#![allow(unused)]
fn main() {
let data = serde_json::json!({
    "user": {
        "profile": {
            "name": "John",
            "bio": "Developer"
        }
    },
    "settings": {
        "notifications": true
    }
});

let errors = Validator::new()
    .rule("user.profile.name", rules![required(), string(), min(2)])
    .rule("user.profile.bio", rules![nullable(), string(), max(500)])
    .rule("settings.notifications", rules![required(), boolean()])
    .validate(&data);
}

Conditional Validation

#![allow(unused)]
fn main() {
let data = serde_json::json!({
    "type": "business",
    "company_name": "Acme Corp",
    "tax_id": "123456789"
});

let errors = Validator::new()
    .rule("type", rules![required(), in_array(vec!["personal", "business"])])
    .rule("company_name", rules![required_if("type", "business"), string()])
    .rule("tax_id", rules![required_if("type", "business"), string()])
    .validate(&data);
}

Custom Messages

Override default error messages for specific fields and rules:

#![allow(unused)]
fn main() {
let errors = Validator::new()
    .rule("email", rules![required(), email()])
    .rule("password", rules![required(), min(8)])
    .message("email.required", "Please provide your email address")
    .message("email.email", "The email format is invalid")
    .message("password.required", "Password is required")
    .message("password.min", "Password must be at least 8 characters")
    .validate(&data);
}

Custom Attributes

Replace field names in error messages with friendlier names:

#![allow(unused)]
fn main() {
let errors = Validator::new()
    .rule("dob", rules![required(), date()])
    .rule("cc_number", rules![required(), string()])
    .attribute("dob", "date of birth")
    .attribute("cc_number", "credit card number")
    .validate(&data);

// Error: "The date of birth field is required"
// Instead of: "The dob field is required"
}

Validation Errors

The ValidationError type collects and manages validation errors:

#![allow(unused)]
fn main() {
use ferro::validation::ValidationError;

let errors: ValidationError = validator.validate(&data);

// Check if validation failed
if errors.fails() {
    // Get first error for a field
    if let Some(message) = errors.first("email") {
        println!("Email error: {}", message);
    }

    // Get all errors for a field
    if let Some(messages) = errors.get("password") {
        for msg in messages {
            println!("Password: {}", msg);
        }
    }

    // Check if specific field has errors
    if errors.has("username") {
        println!("Username has validation errors");
    }

    // Get all errors as HashMap
    let all_errors = errors.all();

    // Get total error count
    println!("Total errors: {}", errors.count());

    // Convert to JSON for API responses
    let json = errors.to_json();
}
}

JSON Error Response

#![allow(unused)]
fn main() {
use ferro::{Response, json_response};

if errors.fails() {
    return json_response!(422, {
        "message": "Validation failed",
        "errors": errors
    });
}
}

Form Requests

Form Requests provide automatic validation and authorization for HTTP requests.

Defining a Form Request

#![allow(unused)]
fn main() {
use ferro::http::FormRequest;
use serde::Deserialize;
use validator::Validate;

#[derive(Debug, Deserialize, Validate)]
pub struct CreateUserRequest {
    #[validate(length(min = 2, max = 50))]
    pub name: String,

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

    #[validate(length(min = 8))]
    pub password: String,

    #[validate(range(min = 13, max = 120))]
    pub age: Option<i32>,
}

impl FormRequest for CreateUserRequest {}
}

Using Form Requests in Handlers

#![allow(unused)]
fn main() {
use ferro::{handler, Response, json_response};
use crate::requests::CreateUserRequest;

#[handler]
pub async fn store(request: CreateUserRequest) -> Response {
    // Request is automatically validated
    // If validation fails, 422 response is returned

    let user = User::create(
        &request.name,
        &request.email,
        &request.password,
    ).await?;

    json_response!(201, { "user": user })
}
}

Authorization

Override the authorize method to add authorization logic:

#![allow(unused)]
fn main() {
use ferro::http::{FormRequest, Request};

impl FormRequest for UpdatePostRequest {
    fn authorize(req: &Request) -> bool {
        // Check if user can update the post
        if let Some(user) = req.user() {
            if let Some(post_id) = req.param("post") {
                return user.can_edit_post(post_id);
            }
        }
        false
    }
}
}

Validation Attributes

The validator crate provides these validation attributes:

#![allow(unused)]
fn main() {
#[derive(Deserialize, Validate)]
pub struct ExampleRequest {
    // Length validation
    #[validate(length(min = 1, max = 100))]
    pub title: String,

    // Email validation
    #[validate(email)]
    pub email: String,

    // URL validation
    #[validate(url)]
    pub website: Option<String>,

    // Range validation
    #[validate(range(min = 0, max = 100))]
    pub score: i32,

    // Regex validation
    #[validate(regex(path = "RE_PHONE"))]
    pub phone: String,

    // Custom validation
    #[validate(custom(function = "validate_username"))]
    pub username: String,

    // Nested validation
    #[validate(nested)]
    pub address: Address,

    // Required (use Option for optional fields)
    pub required_field: String,
    pub optional_field: Option<String>,
}

// Define regex patterns
lazy_static! {
    static ref RE_PHONE: Regex = Regex::new(r"^\+?[1-9]\d{1,14}$").unwrap();
}

// Custom validation function
fn validate_username(username: &str) -> Result<(), validator::ValidationError> {
    if username.contains("admin") {
        return Err(validator::ValidationError::new("username_reserved"));
    }
    Ok(())
}
}

Custom Rules

Create custom validation rules by implementing the Rule trait:

#![allow(unused)]
fn main() {
use ferro::validation::Rule;
use serde_json::Value;

pub struct Uppercase;

impl Rule for Uppercase {
    fn validate(&self, field: &str, value: &Value, _data: &Value) -> Result<(), String> {
        match value {
            Value::String(s) => {
                if s.chars().all(|c| c.is_uppercase() || !c.is_alphabetic()) {
                    Ok(())
                } else {
                    Err(format!("The {} field must be uppercase.", field))
                }
            }
            _ => Err(format!("The {} field must be a string.", field)),
        }
    }

    fn name(&self) -> &'static str {
        "uppercase"
    }
}

// Usage
let errors = Validator::new()
    .rule("code", rules![required(), Uppercase])
    .validate(&data);
}

Custom Rule with Parameters

#![allow(unused)]
fn main() {
pub struct StartsWithRule {
    prefix: String,
}

impl StartsWithRule {
    pub fn new(prefix: impl Into<String>) -> Self {
        Self { prefix: prefix.into() }
    }
}

impl Rule for StartsWithRule {
    fn validate(&self, field: &str, value: &Value, _data: &Value) -> Result<(), String> {
        match value {
            Value::String(s) => {
                if s.starts_with(&self.prefix) {
                    Ok(())
                } else {
                    Err(format!("The {} field must start with {}.", field, self.prefix))
                }
            }
            _ => Err(format!("The {} field must be a string.", field)),
        }
    }

    fn name(&self) -> &'static str {
        "starts_with"
    }
}

// Helper function
pub fn starts_with(prefix: impl Into<String>) -> StartsWithRule {
    StartsWithRule::new(prefix)
}

// Usage
let errors = Validator::new()
    .rule("product_code", rules![required(), starts_with("PRD-")])
    .validate(&data);
}

API Validation Pattern

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, json_response};
use ferro::validation::{Validator, rules};

#[handler]
pub async fn store(req: Request) -> Response {
    let data: serde_json::Value = req.json().await?;

    let errors = Validator::new()
        .rule("title", rules![required(), string(), min(1), max(200)])
        .rule("content", rules![required(), string()])
        .rule("status", rules![required(), in_array(vec!["draft", "published"])])
        .rule("tags", rules![nullable(), array()])
        .message("title.required", "Please provide a title")
        .message("content.required", "Content cannot be empty")
        .validate(&data);

    if errors.fails() {
        return json_response!(422, {
            "message": "The given data was invalid.",
            "errors": errors
        });
    }

    // Proceed with valid data
    let post = Post::create(&data).await?;
    json_response!(201, { "post": post })
}
}

Rules Reference

RuleDescriptionExample
required()Field must be present and not emptyrequired()
required_if(field, value)Required if another field equals valuerequired_if("type", "business")
string()Must be a stringstring()
integer()Must be an integerinteger()
numeric()Must be numericnumeric()
boolean()Must be a booleanboolean()
array()Must be an arrayarray()
min(n)Minimum length/valuemin(8)
max(n)Maximum length/valuemax(255)
between(min, max)Value between min and maxbetween(1, 100)
email()Valid email formatemail()
url()Valid URL formaturl()
regex(pattern)Matches regex patternregex(r"^\d{5}$")
alpha()Only alphabetic charactersalpha()
alpha_num()Only alphanumericalpha_num()
alpha_dash()Alphanumeric, dashes, underscoresalpha_dash()
date()Valid date (YYYY-MM-DD)date()
confirmed()Must match {field}_confirmationconfirmed()
in_array(values)Must be one of valuesin_array(vec!["a", "b"])
not_in(values)Must not be one of valuesnot_in(vec!["x", "y"])
different(field)Must differ from fielddifferent("old_email")
same(field)Must match fieldsame("password")
nullable()Can be null (stops if null)nullable()
accepted()Must be "yes", "on", "1", trueaccepted()

Best Practices

  1. Use Form Requests for complex validation - Keeps controllers clean
  2. Provide custom messages - User-friendly error messages improve UX
  3. Use custom attributes - Replace technical field names with readable ones
  4. Validate early - Fail fast with clear error messages
  5. Use nullable() for optional fields - Prevents errors on missing optional data
  6. Create custom rules - Reuse validation logic across the application
  7. Return 422 status - Standard HTTP status for validation errors
  8. Structure errors as JSON - Easy to consume by frontend applications

Testing

Ferro provides a comprehensive testing suite with HTTP test client, Jest-like assertions, database factories, and isolated test databases.

HTTP Testing

TestClient

The TestClient provides a fluent API for making HTTP requests to your application.

#![allow(unused)]
fn main() {
use ferro::testing::TestClient;

#[tokio::test]
async fn test_homepage() {
    let client = TestClient::new(app());

    let response = client.get("/").send().await;

    response.assert_ok();
    response.assert_see("Welcome");
}
}

Making Requests

#![allow(unused)]
fn main() {
use ferro::testing::TestClient;

let client = TestClient::new(app());

// GET request
let response = client.get("/users").send().await;

// POST request with JSON body
let response = client
    .post("/users")
    .json(&serde_json::json!({
        "name": "John Doe",
        "email": "john@example.com"
    }))
    .send()
    .await;

// PUT request
let response = client
    .put("/users/1")
    .json(&serde_json::json!({ "name": "Jane Doe" }))
    .send()
    .await;

// PATCH request
let response = client
    .patch("/users/1")
    .json(&serde_json::json!({ "active": true }))
    .send()
    .await;

// DELETE request
let response = client.delete("/users/1").send().await;
}

Request Builder

Customize requests with headers, authentication, and body data.

#![allow(unused)]
fn main() {
// With headers
let response = client
    .get("/api/data")
    .header("X-Custom-Header", "value")
    .header("Accept", "application/json")
    .send()
    .await;

// With bearer token authentication
let response = client
    .get("/api/protected")
    .bearer_token("your-jwt-token")
    .send()
    .await;

// With query parameters
let response = client
    .get("/search")
    .query(&[("q", "rust"), ("page", "1")])
    .send()
    .await;

// With form data
let response = client
    .post("/login")
    .form(&[("email", "user@example.com"), ("password", "secret")])
    .send()
    .await;

// With JSON body
let response = client
    .post("/api/posts")
    .json(&serde_json::json!({
        "title": "My Post",
        "content": "Hello, World!"
    }))
    .send()
    .await;
}

Acting As User

Test authenticated routes by acting as a specific user.

#![allow(unused)]
fn main() {
use ferro::models::user;

let user = user::Entity::find_by_pk(1).await?.unwrap();

let response = client
    .get("/dashboard")
    .acting_as(&user)
    .send()
    .await;

response.assert_ok();
}

Response Assertions

Status Assertions

#![allow(unused)]
fn main() {
// Specific status code
response.assert_status(200);
response.assert_status(201);
response.assert_status(422);

// Common status helpers
response.assert_ok();           // 200
response.assert_created();      // 201
response.assert_no_content();   // 204
response.assert_redirect();     // 3xx
response.assert_not_found();    // 404
response.assert_unauthorized(); // 401
response.assert_forbidden();    // 403
response.assert_unprocessable(); // 422
response.assert_server_error(); // 5xx
}

JSON Assertions

#![allow(unused)]
fn main() {
// Assert JSON path exists
response.assert_json_has("data.user.name");
response.assert_json_has("data.items[0].id");

// Assert JSON path has specific value
response.assert_json_is("data.user.name", "John Doe");
response.assert_json_is("data.count", 42);
response.assert_json_is("data.active", true);

// Assert entire JSON structure
response.assert_json_equals(&serde_json::json!({
    "status": "success",
    "data": {
        "id": 1,
        "name": "John"
    }
}));

// Assert array count at path
response.assert_json_count("data.items", 5);

// Assert JSON matches predicate
response.assert_json_matches("data.items", |items| {
    items.as_array().map(|arr| arr.len() > 0).unwrap_or(false)
});

// Assert JSON path is missing
response.assert_json_missing("data.password");
}

Content Assertions

#![allow(unused)]
fn main() {
// Assert body contains text
response.assert_see("Welcome");
response.assert_see("Hello, World!");

// Assert body does NOT contain text
response.assert_dont_see("Error");
response.assert_dont_see("Unauthorized");
}

Validation Error Assertions

#![allow(unused)]
fn main() {
// Assert validation errors for specific fields
response.assert_validation_errors(&["email", "password"]);

// Typical validation error test
#[tokio::test]
async fn test_registration_validation() {
    let client = TestClient::new(app());

    let response = client
        .post("/register")
        .json(&serde_json::json!({
            "email": "invalid-email",
            "password": "123"  // too short
        }))
        .send()
        .await;

    response.assert_unprocessable();
    response.assert_validation_errors(&["email", "password"]);
}
}

Header Assertions

#![allow(unused)]
fn main() {
// Assert header exists and has value
response.assert_header("Content-Type", "application/json");
response.assert_header("X-Request-Id", "abc123");
}

Accessing Response Data

#![allow(unused)]
fn main() {
// Get status code
let status = response.status();

// Get response body as string
let body = response.text();

// Get response body as JSON
let json: serde_json::Value = response.json();

// Get specific header
let content_type = response.header("Content-Type");
}

Expect Assertions

Ferro provides Jest-like expect assertions for expressive tests.

#![allow(unused)]
fn main() {
use ferro::testing::Expect;

#[tokio::test]
async fn test_user_creation() {
    let user = create_user().await;

    Expect::that(&user.name).to_equal("John Doe");
    Expect::that(&user.email).to_contain("@");
    Expect::that(&user.age).to_be_greater_than(&18);
}
}

Equality

#![allow(unused)]
fn main() {
Expect::that(&value).to_equal("expected");
Expect::that(&value).to_not_equal("unexpected");
}

Boolean

#![allow(unused)]
fn main() {
Expect::that(&result).to_be_true();
Expect::that(&result).to_be_false();
}

Option

#![allow(unused)]
fn main() {
let some_value: Option<i32> = Some(42);
let none_value: Option<i32> = None;

Expect::that(&some_value).to_be_some();
Expect::that(&none_value).to_be_none();
Expect::that(&some_value).to_contain_value(&42);
}

Result

#![allow(unused)]
fn main() {
let ok_result: Result<i32, &str> = Ok(42);
let err_result: Result<i32, &str> = Err("error");

Expect::that(&ok_result).to_be_ok();
Expect::that(&err_result).to_be_err();
Expect::that(&ok_result).to_contain_ok(&42);
Expect::that(&err_result).to_contain_err(&"error");
}

Strings

#![allow(unused)]
fn main() {
Expect::that(&text).to_contain("hello");
Expect::that(&text).to_start_with("Hello");
Expect::that(&text).to_end_with("!");
Expect::that(&text).to_have_length(11);
Expect::that(&text).to_be_empty();
Expect::that(&text).to_not_be_empty();
}

Vectors

#![allow(unused)]
fn main() {
let items = vec![1, 2, 3, 4, 5];

Expect::that(&items).to_have_length(5);
Expect::that(&items).to_contain(&3);
Expect::that(&items).to_be_empty();
Expect::that(&items).to_not_be_empty();
}

Numeric Comparisons

#![allow(unused)]
fn main() {
Expect::that(&value).to_be_greater_than(&10);
Expect::that(&value).to_be_less_than(&100);
Expect::that(&value).to_be_greater_than_or_equal(&10);
Expect::that(&value).to_be_less_than_or_equal(&100);
}

Database Factories

Factories generate fake data for testing, inspired by Laravel's model factories.

Defining a Factory

#![allow(unused)]
fn main() {
use ferro::testing::{Factory, FactoryBuilder, Fake};
use sea_orm::Set;
use crate::models::user;

pub struct UserFactory;

impl Factory for UserFactory {
    type Model = user::ActiveModel;

    fn definition() -> Self::Model {
        user::ActiveModel {
            name: Set(Fake::name()),
            email: Set(Fake::email()),
            password: Set(Fake::sentence(3)),
            active: Set(true),
            ..Default::default()
        }
    }
}
}

Using Factories

#![allow(unused)]
fn main() {
use crate::factories::UserFactory;

// Make a single model (not persisted)
let user = UserFactory::factory().make();

// Make multiple models
let users = UserFactory::factory().count(5).make_many();

// Override attributes
let admin = UserFactory::factory()
    .set("role", "admin")
    .set("active", true)
    .make();
}

Factory States

Define reusable states for common variations.

#![allow(unused)]
fn main() {
use ferro::testing::{Factory, FactoryTraits};

impl Factory for UserFactory {
    type Model = user::ActiveModel;

    fn definition() -> Self::Model {
        user::ActiveModel {
            name: Set(Fake::name()),
            email: Set(Fake::email()),
            active: Set(true),
            role: Set("user".to_string()),
            ..Default::default()
        }
    }

    fn traits() -> FactoryTraits<Self::Model> {
        FactoryTraits::new()
            .register("admin", |model| {
                let mut model = model;
                model.role = Set("admin".to_string());
                model
            })
            .register("inactive", |model| {
                let mut model = model;
                model.active = Set(false);
                model
            })
            .register("unverified", |model| {
                let mut model = model;
                model.email_verified_at = Set(None);
                model
            })
    }
}

// Using traits
let admin = UserFactory::factory().trait_("admin").make();
let inactive_admin = UserFactory::factory()
    .trait_("admin")
    .trait_("inactive")
    .make();
}

Database Factory

For factories that persist to the database.

#![allow(unused)]
fn main() {
use ferro::testing::{DatabaseFactory, Factory, Fake};
use ferro::DB;

pub struct UserFactory;

impl Factory for UserFactory {
    type Model = user::ActiveModel;

    fn definition() -> Self::Model {
        user::ActiveModel {
            name: Set(Fake::name()),
            email: Set(Fake::email()),
            ..Default::default()
        }
    }
}

impl DatabaseFactory for UserFactory {
    type Entity = user::Entity;
}

// Create and persist to database
let user = UserFactory::factory().create().await?;

// Create multiple
let users = UserFactory::factory().count(10).create_many().await?;
}

Factory Callbacks

Execute code after making or creating models.

#![allow(unused)]
fn main() {
let user = UserFactory::factory()
    .after_make(|user| {
        println!("Made user: {:?}", user);
    })
    .after_create(|user| {
        // Send welcome email, create related records, etc.
        println!("Created user in database: {:?}", user);
    })
    .create()
    .await?;
}

Sequences

Generate unique sequential values.

#![allow(unused)]
fn main() {
use ferro::testing::Sequence;

let seq = Sequence::new();

// Get next value
let id1 = seq.next(); // 1
let id2 = seq.next(); // 2
let id3 = seq.next(); // 3

// Use in factories
fn definition() -> Self::Model {
    static SEQ: Sequence = Sequence::new();

    user::ActiveModel {
        email: Set(format!("user{}@example.com", SEQ.next())),
        ..Default::default()
    }
}
}

Fake Data Generation

The Fake helper generates realistic test data.

Personal Information

#![allow(unused)]
fn main() {
use ferro::testing::Fake;

let name = Fake::name();           // "John Smith"
let first = Fake::first_name();    // "John"
let last = Fake::last_name();      // "Smith"
let email = Fake::email();         // "john.smith@example.com"
let phone = Fake::phone();         // "+1-555-123-4567"
}

Text Content

#![allow(unused)]
fn main() {
let word = Fake::word();               // "lorem"
let sentence = Fake::sentence(5);      // 5-word sentence
let paragraph = Fake::paragraph(3);    // 3-sentence paragraph
let text = Fake::text(100);            // ~100 characters
}

Numbers

#![allow(unused)]
fn main() {
let num = Fake::number(1, 100);        // Random 1-100
let float = Fake::float(0.0, 1.0);     // Random 0.0-1.0
let bool = Fake::boolean();            // true or false
}

Identifiers

#![allow(unused)]
fn main() {
let uuid = Fake::uuid();               // "550e8400-e29b-41d4-a716-446655440000"
let slug = Fake::slug(3);              // "lorem-ipsum-dolor"
}

Addresses

#![allow(unused)]
fn main() {
let address = Fake::address();         // "123 Main St"
let city = Fake::city();               // "New York"
let country = Fake::country();         // "United States"
let zip = Fake::zip_code();            // "10001"
}

Internet

#![allow(unused)]
fn main() {
let url = Fake::url();                 // "https://example.com/page"
let domain = Fake::domain();           // "example.com"
let ip = Fake::ip_v4();                // "192.168.1.1"
let user_agent = Fake::user_agent();   // "Mozilla/5.0..."
}

Dates and Times

#![allow(unused)]
fn main() {
use chrono::{NaiveDate, NaiveDateTime};

let date = Fake::date();               // Random date
let datetime = Fake::datetime();       // Random datetime
let past = Fake::past_date(30);        // Within last 30 days
let future = Fake::future_date(30);    // Within next 30 days
}

Collections

#![allow(unused)]
fn main() {
// Pick one from list
let status = Fake::one_of(&["pending", "active", "completed"]);

// Pick multiple from list
let tags = Fake::many_of(&["rust", "web", "api", "testing"], 2);
}

Custom Generators

#![allow(unused)]
fn main() {
// With closure
let custom = Fake::custom(|| {
    format!("USER-{}", Fake::number(1000, 9999))
});
}

Test Database

Ferro provides isolated database testing with automatic migrations.

Using test_database! Macro

#![allow(unused)]
fn main() {
use ferro::test_database;
use ferro::models::user;

#[tokio::test]
async fn test_user_creation() {
    // Creates fresh in-memory SQLite with migrations
    let db = test_database!();

    // Create a user
    let new_user = user::ActiveModel {
        name: Set("Test User".to_string()),
        email: Set("test@example.com".to_string()),
        ..Default::default()
    };

    let user = user::Entity::insert_one(new_user).await.unwrap();
    assert!(user.id > 0);

    // Query using test database connection
    let found = user::Entity::find_by_id(user.id)
        .one(db.conn())
        .await
        .unwrap();

    assert!(found.is_some());
}
}

Custom Migrator

#![allow(unused)]
fn main() {
use ferro::testing::TestDatabase;

#[tokio::test]
async fn test_with_custom_migrator() {
    let db = TestDatabase::fresh::<my_crate::CustomMigrator>()
        .await
        .unwrap();

    // Test code here
}
}

Database Isolation

Each TestDatabase:

  • Creates a fresh in-memory SQLite database
  • Runs all migrations automatically
  • Is completely isolated from other tests
  • Is cleaned up when dropped

This ensures tests don't interfere with each other.

Test Container

Mock dependencies using the test container.

Faking Services

#![allow(unused)]
fn main() {
use ferro::testing::{TestContainer, TestContainerGuard};
use std::sync::Arc;

#[tokio::test]
async fn test_with_fake_service() {
    // Create isolated container for this test
    let _guard = TestContainer::fake();

    // Register a fake singleton
    TestContainer::singleton(FakePaymentGateway::new());

    // Register a factory
    TestContainer::factory(|| {
        Box::new(MockEmailService::default())
    });

    // Your test code - Container::get() returns fakes
    let gateway = Container::get::<FakePaymentGateway>();
    // ...
}
}

Binding Interfaces

#![allow(unused)]
fn main() {
use std::sync::Arc;

#[tokio::test]
async fn test_with_mock_repository() {
    let _guard = TestContainer::fake();

    // Bind a mock implementation of a trait
    let mock_repo: Arc<dyn UserRepository> = Arc::new(MockUserRepository::new());
    TestContainer::bind(mock_repo);

    // Or with a factory
    TestContainer::bind_factory::<dyn UserRepository, _>(|| {
        Arc::new(MockUserRepository::with_users(vec![
            User { id: 1, name: "Test".into() }
        ]))
    });

    // Test code uses the mock
}
}

Complete Test Example

#![allow(unused)]
fn main() {
use ferro::testing::{TestClient, TestDatabase, Expect, Fake};
use ferro::test_database;
use crate::factories::UserFactory;

#[tokio::test]
async fn test_user_registration_flow() {
    // Set up isolated test database
    let _db = test_database!();

    // Create test client
    let client = TestClient::new(app());

    // Test validation errors
    let response = client
        .post("/api/register")
        .json(&serde_json::json!({
            "email": "invalid"
        }))
        .send()
        .await;

    response.assert_unprocessable();
    response.assert_validation_errors(&["email", "password", "name"]);

    // Test successful registration
    let email = Fake::email();
    let response = client
        .post("/api/register")
        .json(&serde_json::json!({
            "name": Fake::name(),
            "email": &email,
            "password": "password123",
            "password_confirmation": "password123"
        }))
        .send()
        .await;

    response.assert_created();
    response.assert_json_has("data.user.id");
    response.assert_json_is("data.user.email", &email);

    // Verify user was created in database
    let user = user::Entity::query()
        .filter(user::Column::Email.eq(&email))
        .first()
        .await
        .unwrap();

    Expect::that(&user).to_be_some();
}

#[tokio::test]
async fn test_authenticated_endpoint() {
    let _db = test_database!();

    // Create a user with factory
    let user = UserFactory::factory()
        .trait_("admin")
        .create()
        .await
        .unwrap();

    let client = TestClient::new(app());

    // Test unauthorized access
    let response = client.get("/api/admin/users").send().await;
    response.assert_unauthorized();

    // Test authorized access
    let response = client
        .get("/api/admin/users")
        .acting_as(&user)
        .send()
        .await;

    response.assert_ok();
    response.assert_json_has("data.users");
}
}

Running Tests

# Run all tests
cargo test

# Run specific test
cargo test test_user_registration

# Run tests with output
cargo test -- --nocapture

# Run tests in parallel (default)
cargo test

# Run tests sequentially
cargo test -- --test-threads=1

Best Practices

  1. Use test_database! for isolation - Each test gets a fresh database
  2. Use factories for test data - Consistent, readable test setup
  3. Test both success and failure cases - Validate error handling
  4. Use meaningful test names - test_user_cannot_access_admin_panel
  5. Keep tests focused - One assertion concept per test
  6. Use Expect for readable assertions - Fluent API improves clarity
  7. Mock external services - Use TestContainer to isolate from APIs
  8. Test validation thoroughly - Cover edge cases in input validation

Inertia.js

Ferro provides first-class Inertia.js integration, enabling you to build modern single-page applications using React while keeping your routing and controllers on the server. This gives you the best of both worlds: the snappy feel of an SPA with the simplicity of server-side rendering.

How Inertia Works

Inertia.js is a protocol that connects your server-side framework to a client-side framework (React, Vue, or Svelte). Instead of returning HTML or building a separate API:

  1. Your controller returns an Inertia response with a component name and props
  2. On the first request, a full HTML page is rendered with the initial data
  3. On subsequent requests, only JSON is returned
  4. The client-side adapter swaps components without full page reloads

Configuration

Environment Variables

Configure Inertia in your .env file:

# Vite development server URL
VITE_DEV_SERVER=http://localhost:5173

# Frontend entry point
VITE_ENTRY_POINT=src/main.tsx

# Asset version for cache busting
INERTIA_VERSION=1.0

# Development mode (enables HMR)
APP_ENV=development

Bootstrap Setup

In src/bootstrap.rs, configure Inertia:

#![allow(unused)]
fn main() {
use ferro::{App, InertiaConfig};

pub async fn register() {
    // Configure from environment
    let config = InertiaConfig::from_env();
    App::set_inertia_config(config);
}
}

Manual Configuration

#![allow(unused)]
fn main() {
use ferro::InertiaConfig;

let config = InertiaConfig {
    vite_dev_server: "http://localhost:5173".to_string(),
    entry_point: "src/main.tsx".to_string(),
    version: "1.0".to_string(),
    development: true,
    html_template: None,
};
}

Basic Usage

Rendering Responses

Use Inertia::render() to return an Inertia response:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response};
use ferro::inertia::Inertia;
use serde::Serialize;

#[derive(Serialize)]
pub struct HomeProps {
    pub title: String,
    pub message: String,
}

#[handler]
pub async fn index(req: Request) -> Response {
    Inertia::render(&req, "Home", HomeProps {
        title: "Welcome".to_string(),
        message: "Hello from Ferro!".to_string(),
    })
}
}

The component name ("Home") maps to frontend/src/pages/Home.tsx.

The InertiaProps Derive Macro

For automatic camelCase conversion (standard in JavaScript), use the InertiaProps derive macro:

#![allow(unused)]
fn main() {
use ferro::InertiaProps;

#[derive(InertiaProps)]
pub struct DashboardProps {
    pub user_name: String,      // Serializes as "userName"
    pub total_posts: i32,       // Serializes as "totalPosts"
    pub is_admin: bool,         // Serializes as "isAdmin"
}

#[handler]
pub async fn dashboard(req: Request) -> Response {
    Inertia::render(&req, "Dashboard", DashboardProps {
        user_name: "John".to_string(),
        total_posts: 42,
        is_admin: true,
    })
}
}

In your React component:

interface DashboardProps {
    userName: string;
    totalPosts: number;
    isAdmin: boolean;
}

export default function Dashboard({ userName, totalPosts, isAdmin }: DashboardProps) {
    return <h1>Welcome, {userName}!</h1>;
}

Compile-Time Component Validation

The inertia_response! macro validates that your component exists at compile time:

#![allow(unused)]
fn main() {
use ferro::inertia_response;

#[handler]
pub async fn show(req: Request) -> Response {
    // Validates that frontend/src/pages/Users/Show.tsx exists
    inertia_response!(&req, "Users/Show", UserProps { ... })
}
}

If the component doesn't exist, you get a compile error with fuzzy matching suggestions:

error: Component "Users/Shwo" not found. Did you mean "Users/Show"?

Shared Props

Shared props are data that should be available to every page component, like authentication state, flash messages, and CSRF tokens.

Creating the Middleware

#![allow(unused)]
fn main() {
use ferro::{Middleware, Request, Response, Next};
use ferro::inertia::InertiaShared;
use async_trait::async_trait;

pub struct ShareInertiaData;

#[async_trait]
impl Middleware for ShareInertiaData {
    async fn handle(&self, mut request: Request, next: Next) -> Response {
        let mut shared = InertiaShared::new();

        // Add CSRF token
        if let Some(token) = request.csrf_token() {
            shared = shared.csrf(token);
        }

        // Add authenticated user
        if let Some(user) = request.user() {
            shared = shared.auth(AuthUser {
                id: user.id,
                name: user.name.clone(),
                email: user.email.clone(),
            });
        }

        // Add flash messages
        if let Some(flash) = request.session().get::<FlashMessages>("flash") {
            shared = shared.flash(flash);
        }

        // Add custom shared data
        shared = shared.with(serde_json::json!({
            "app_name": "My Application",
            "app_version": "1.0.0",
        }));

        // Store in request extensions
        request.insert(shared);
        next(request).await
    }
}
}

Registering the Middleware

In src/bootstrap.rs:

#![allow(unused)]
fn main() {
use ferro::global_middleware;
use crate::middleware::ShareInertiaData;

pub async fn register() {
    global_middleware!(ShareInertiaData);
}
}

Using Shared Props in Controllers

When InertiaShared is in the request extensions, it's automatically merged:

#![allow(unused)]
fn main() {
#[handler]
pub async fn index(req: Request) -> Response {
    // Shared props (auth, flash, csrf) are automatically included
    Inertia::render(&req, "Home", HomeProps {
        title: "Welcome".to_string(),
    })
}
}

Accessing Shared Props in React

import { usePage } from '@inertiajs/react';

interface SharedProps {
    auth?: {
        id: number;
        name: string;
        email: string;
    };
    flash?: {
        success?: string;
        error?: string;
    };
    csrf?: string;
}

export default function Layout({ children }) {
    const { auth, flash } = usePage<{ props: SharedProps }>().props;

    return (
        <div>
            {auth && <nav>Welcome, {auth.name}</nav>}
            {flash?.success && <div className="alert-success">{flash.success}</div>}
            {children}
        </div>
    );
}

SavedInertiaContext

When you need to consume the request body (e.g., for validation) before rendering, use SavedInertiaContext:

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response};
use ferro::inertia::{Inertia, SavedInertiaContext};
use ferro::validation::{Validator, rules};

#[handler]
pub async fn store(req: Request) -> Response {
    // Save context BEFORE consuming the request
    let ctx = SavedInertiaContext::from_request(&req);

    // Now consume the request body
    let data: serde_json::Value = req.json().await?;

    // Validate
    let errors = Validator::new()
        .rule("title", rules![required(), string(), min(1)])
        .rule("content", rules![required(), string()])
        .validate(&data);

    if errors.fails() {
        // Use saved context to render with validation errors
        return Inertia::render_ctx(&ctx, "Posts/Create", CreatePostProps {
            errors: errors.to_json(),
            old: data,
        });
    }

    // Create the post...
    let post = Post::create(&data).await?;

    redirect!(format!("/posts/{}", post.id))
}
}

Frontend Setup

Project Structure

your-app/
├── src/                    # Rust backend
│   ├── controllers/
│   ├── middleware/
│   └── main.rs
├── frontend/               # React frontend
│   ├── src/
│   │   ├── pages/          # Inertia page components
│   │   │   ├── Home.tsx
│   │   │   ├── Dashboard.tsx
│   │   │   └── Users/
│   │   │       ├── Index.tsx
│   │   │       └── Show.tsx
│   │   ├── components/     # Shared components
│   │   ├── layouts/        # Layout components
│   │   └── main.tsx        # Entry point
│   ├── package.json
│   └── vite.config.ts
└── Cargo.toml

Entry Point (main.tsx)

import { createInertiaApp } from '@inertiajs/react';
import { createRoot } from 'react-dom/client';

createInertiaApp({
    resolve: (name) => {
        const pages = import.meta.glob('./pages/**/*.tsx', { eager: true });
        return pages[`./pages/${name}.tsx`];
    },
    setup({ el, App, props }) {
        createRoot(el).render(<App {...props} />);
    },
});

Vite Configuration

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
    plugins: [react()],
    server: {
        port: 5173,
        strictPort: true,
    },
    build: {
        manifest: true,
        outDir: '../public/build',
        rollupOptions: {
            input: 'src/main.tsx',
        },
    },
});

Package Dependencies

{
    "dependencies": {
        "@inertiajs/react": "^1.0.0",
        "react": "^18.2.0",
        "react-dom": "^18.2.0"
    },
    "devDependencies": {
        "@types/react": "^18.2.0",
        "@types/react-dom": "^18.2.0",
        "@vitejs/plugin-react": "^4.0.0",
        "typescript": "^5.0.0",
        "vite": "^5.0.0"
    }
}

Use the Inertia Link component for client-side navigation:

import { Link } from '@inertiajs/react';

export default function Navigation() {
    return (
        <nav>
            <Link href="/">Home</Link>
            <Link href="/about">About</Link>
            <Link href="/users" method="get" as="button">Users</Link>
        </nav>
    );
}

Programmatic Navigation

import { router } from '@inertiajs/react';

function handleClick() {
    router.visit('/dashboard');
}

function handleSubmit(data) {
    router.post('/posts', data, {
        onSuccess: () => {
            // Handle success
        },
    });
}

Partial Reloads

Inertia supports partial reloads to refresh only specific props without a full page reload.

Requesting Partial Data

import { router } from '@inertiajs/react';

// Only reload the 'users' prop
router.reload({ only: ['users'] });

// Reload specific props
router.visit('/dashboard', {
    only: ['notifications', 'messages'],
});

Server-Side Handling

Ferro automatically handles partial reload requests. The X-Inertia-Partial-Data header specifies which props to return:

#![allow(unused)]
fn main() {
#[handler]
pub async fn dashboard(req: Request) -> Response {
    // All props are computed, but only requested ones are sent
    Inertia::render(&req, "Dashboard", DashboardProps {
        user: get_user().await?,          // Always sent on full load
        notifications: get_notifications().await?,  // Only if requested
        stats: get_stats().await?,        // Only if requested
    })
}
}

Version Conflict Handling

When your assets change (new deployment), Inertia uses versioning to force a full page reload.

Checking Version

#![allow(unused)]
fn main() {
use ferro::inertia::Inertia;

#[handler]
pub async fn index(req: Request) -> Response {
    // Check if client version matches
    if let Some(response) = Inertia::check_version(&req, "1.0", "/") {
        return response;  // Returns 409 Conflict
    }

    Inertia::render(&req, "Home", HomeProps { ... })
}
}

Middleware Approach

#![allow(unused)]
fn main() {
pub struct InertiaVersionCheck;

#[async_trait]
impl Middleware for InertiaVersionCheck {
    async fn handle(&self, request: Request, next: Next) -> Response {
        let current_version = std::env::var("INERTIA_VERSION")
            .unwrap_or_else(|_| "1.0".to_string());

        if let Some(response) = Inertia::check_version(&request, &current_version, "/") {
            return response;
        }

        next(request).await
    }
}
}

Forms

Basic Form Handling

import { useForm } from '@inertiajs/react';

export default function CreatePost() {
    const { data, setData, post, processing, errors } = useForm({
        title: '',
        content: '',
    });

    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        post('/posts');
    }

    return (
        <form onSubmit={handleSubmit}>
            <input
                value={data.title}
                onChange={e => setData('title', e.target.value)}
            />
            {errors.title && <span>{errors.title}</span>}

            <textarea
                value={data.content}
                onChange={e => setData('content', e.target.value)}
            />
            {errors.content && <span>{errors.content}</span>}

            <button type="submit" disabled={processing}>
                Create Post
            </button>
        </form>
    );
}

Server-Side Validation Response

#![allow(unused)]
fn main() {
use ferro::inertia::{Inertia, SavedInertiaContext};

#[handler]
pub async fn store(req: Request) -> Response {
    let ctx = SavedInertiaContext::from_request(&req);
    let data: CreatePostRequest = req.json().await?;

    let errors = validate_post(&data);
    if errors.fails() {
        // Return to form with errors
        return Inertia::render_ctx(&ctx, "Posts/Create", CreatePostProps {
            errors: errors.to_json(),
        });
    }

    let post = Post::create(&data).await?;
    redirect!(format!("/posts/{}", post.id))
}
}

TypeScript Generation

Ferro can generate TypeScript types from your Rust props:

ferro generate-types

This creates type definitions for your InertiaProps structs:

// Generated: frontend/src/types/props.d.ts
export interface HomeProps {
    title: string;
    message: string;
}

export interface DashboardProps {
    userName: string;
    totalPosts: number;
    isAdmin: boolean;
}

Development vs Production

Development Mode

In development, Ferro serves the Vite dev server with HMR:

#![allow(unused)]
fn main() {
let config = InertiaConfig {
    development: true,
    vite_dev_server: "http://localhost:5173".to_string(),
    // ...
};
}

The rendered HTML includes:

<script type="module" src="http://localhost:5173/@vite/client"></script>
<script type="module" src="http://localhost:5173/src/main.tsx"></script>

Production Mode

In production, Ferro uses the built manifest:

#![allow(unused)]
fn main() {
let config = InertiaConfig {
    development: false,
    // ...
};
}

The rendered HTML includes hashed assets:

<script type="module" src="/build/assets/main-abc123.js"></script>
<link rel="stylesheet" href="/build/assets/main-def456.css">

Example: Complete CRUD

Routes

#![allow(unused)]
fn main() {
use ferro::{get, post, put, delete};

pub fn routes() -> Vec<Route> {
    vec![
        get!("/posts", controllers::posts::index),
        get!("/posts/create", controllers::posts::create),
        post!("/posts", controllers::posts::store),
        get!("/posts/{post}", controllers::posts::show),
        get!("/posts/{post}/edit", controllers::posts::edit),
        put!("/posts/{post}", controllers::posts::update),
        delete!("/posts/{post}", controllers::posts::destroy),
    ]
}
}

Controller

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, redirect};
use ferro::inertia::{Inertia, SavedInertiaContext};
use ferro::InertiaProps;

#[derive(InertiaProps)]
pub struct IndexProps {
    pub posts: Vec<Post>,
}

#[derive(InertiaProps)]
pub struct ShowProps {
    pub post: Post,
}

#[derive(InertiaProps)]
pub struct FormProps {
    pub post: Option<Post>,
    pub errors: Option<serde_json::Value>,
}

#[handler]
pub async fn index(req: Request) -> Response {
    let posts = Post::all().await?;
    Inertia::render(&req, "Posts/Index", IndexProps { posts })
}

#[handler]
pub async fn create(req: Request) -> Response {
    Inertia::render(&req, "Posts/Create", FormProps {
        post: None,
        errors: None,
    })
}

#[handler]
pub async fn store(req: Request) -> Response {
    let ctx = SavedInertiaContext::from_request(&req);
    let data: CreatePostInput = req.json().await?;

    match Post::create(&data).await {
        Ok(post) => redirect!(format!("/posts/{}", post.id)),
        Err(errors) => Inertia::render_ctx(&ctx, "Posts/Create", FormProps {
            post: None,
            errors: Some(errors.to_json()),
        }),
    }
}

#[handler]
pub async fn show(post: Post, req: Request) -> Response {
    Inertia::render(&req, "Posts/Show", ShowProps { post })
}

#[handler]
pub async fn edit(post: Post, req: Request) -> Response {
    Inertia::render(&req, "Posts/Edit", FormProps {
        post: Some(post),
        errors: None,
    })
}

#[handler]
pub async fn update(post: Post, req: Request) -> Response {
    let ctx = SavedInertiaContext::from_request(&req);
    let data: UpdatePostInput = req.json().await?;

    match post.update(&data).await {
        Ok(post) => redirect!(format!("/posts/{}", post.id)),
        Err(errors) => Inertia::render_ctx(&ctx, "Posts/Edit", FormProps {
            post: Some(post),
            errors: Some(errors.to_json()),
        }),
    }
}

#[handler]
pub async fn destroy(post: Post, _req: Request) -> Response {
    post.delete().await?;
    redirect!("/posts")
}
}

Redirects

For form submissions (POST, PUT, PATCH, DELETE) that should redirect after success, use Inertia::redirect():

#![allow(unused)]
fn main() {
use ferro::{Inertia, Request, Response, Auth};

pub async fn login(req: Request) -> Response {
    // ... validation and auth logic ...

    Auth::login(user.id);
    Inertia::redirect(&req, "/dashboard")
}

pub async fn logout(req: Request) -> Response {
    Auth::logout();
    Inertia::redirect(&req, "/")
}
}

Why Not redirect!()?

The redirect!() macro doesn't have access to the request context, so it can't detect Inertia XHR requests. For non-Inertia routes (API endpoints, traditional forms), redirect!() works fine.

For Inertia pages, always use Inertia::redirect() which:

  • Detects Inertia XHR requests via the X-Inertia header
  • Uses 303 status for POST/PUT/PATCH/DELETE (forces GET on redirect)
  • Includes proper X-Inertia: true response header

With Saved Context

If you've consumed the request with req.input(), use the saved context:

#![allow(unused)]
fn main() {
use ferro::{Inertia, Request, Response, SavedInertiaContext};

pub async fn store(req: Request) -> Response {
    let ctx = SavedInertiaContext::from(&req);
    let form: CreateForm = req.input().await?;

    // ... create record ...

    Inertia::redirect_ctx(&ctx, "/items")
}
}

Best Practices

  1. Use InertiaProps derive - Automatic camelCase conversion matches JavaScript conventions
  2. Save context before consuming request - Use SavedInertiaContext for validation flows
  3. Share common data via middleware - Auth, flash, CSRF in ShareInertiaData
  4. Organize pages in folders - Posts/Index.tsx, Posts/Show.tsx for clarity
  5. Use compile-time validation - inertia_response! macro catches typos early
  6. Handle version conflicts - Ensure smooth deployments with version checking
  7. Keep props minimal - Only send what the page needs
  8. Use partial reloads - Optimize updates by requesting only changed data
  9. Use Inertia::redirect() for form success - Ensures proper 303 status for Inertia XHR requests

CLI Reference

Ferro provides a powerful CLI tool for project scaffolding, code generation, database management, and development workflow automation.

Installation

cargo install ferro-cli

Or build from source:

git clone https://github.com/ferroframework/ferro
cd ferro/ferro-cli
cargo install --path .

Project Commands

ferro new

Create a new Ferro project with the complete directory structure.

# Interactive mode (prompts for project name)
ferro new

# Direct creation
ferro new my-app

# Skip git initialization
ferro new my-app --no-git

# Non-interactive mode (uses defaults)
ferro new my-app --no-interaction

Options:

OptionDescription
--no-interactionSkip prompts, use defaults
--no-gitDon't initialize git repository

Generated Structure:

my-app/
├── src/
│   ├── main.rs
│   ├── bootstrap.rs
│   ├── routes.rs
│   ├── controllers/
│   │   └── mod.rs
│   ├── middleware/
│   │   ├── mod.rs
│   │   └── cors.rs
│   ├── models/
│   │   └── mod.rs
│   ├── migrations/
│   │   └── mod.rs
│   ├── events/
│   │   └── mod.rs
│   ├── listeners/
│   │   └── mod.rs
│   ├── jobs/
│   │   └── mod.rs
│   ├── notifications/
│   │   └── mod.rs
│   ├── tasks/
│   │   └── mod.rs
│   ├── seeders/
│   │   └── mod.rs
│   └── factories/
│       └── mod.rs
├── frontend/
│   ├── src/
│   │   ├── pages/
│   │   │   └── Home.tsx
│   │   ├── layouts/
│   │   │   └── Layout.tsx
│   │   ├── types/
│   │   │   └── inertia.d.ts
│   │   ├── app.tsx
│   │   └── main.tsx
│   ├── package.json
│   ├── tsconfig.json
│   ├── vite.config.ts
│   └── tailwind.config.js
├── Cargo.toml
├── .env
├── .env.example
└── .gitignore

Development Commands

ferro serve

Start the development server with hot reloading for both backend and frontend.

# Start both backend and frontend
ferro serve

# Custom ports
ferro serve --port 8080 --frontend-port 5173

# Backend only (no frontend dev server)
ferro serve --backend-only

# Frontend only (no Rust compilation)
ferro serve --frontend-only

# Skip TypeScript type generation
ferro serve --skip-types

Options:

OptionDefaultDescription
--port3000Backend server port
--frontend-port5173Frontend dev server port
--backend-onlyfalseRun only the backend
--frontend-onlyfalseRun only the frontend
--skip-typesfalseDon't regenerate TypeScript types

What it does:

  1. Starts the Rust backend with cargo watch for hot reloading
  2. Starts the Vite frontend dev server
  3. Watches Rust files to regenerate TypeScript types automatically
  4. Proxies frontend requests to the backend

ferro generate-types

Generate TypeScript type definitions from Rust InertiaProps structs.

ferro generate-types

This scans your Rust code for structs deriving InertiaProps and generates corresponding TypeScript interfaces in frontend/src/types/.

Code Generators

All generators follow the pattern ferro make:<type> <name> [options].

ferro make:controller

Generate a controller with handler methods.

# Basic controller
ferro make:controller UserController

# Resource controller with CRUD methods
ferro make:controller PostController --resource

# API controller (JSON responses)
ferro make:controller Api/ProductController --api

Options:

OptionDescription
--resourceGenerate index, show, create, store, edit, update, destroy methods
--apiGenerate API-style controller (JSON responses)

Generated file: src/controllers/user_controller.rs

#![allow(unused)]
fn main() {
use ferro::{handler, Request, Response, json_response};

#[handler]
pub async fn index(req: Request) -> Response {
    // TODO: Implement
    json_response!({ "message": "index" })
}

#[handler]
pub async fn show(req: Request) -> Response {
    // TODO: Implement
    json_response!({ "message": "show" })
}

// ... additional methods for --resource
}

ferro make:middleware

Generate middleware for request/response processing.

ferro make:middleware Auth
ferro make:middleware RateLimit

Generated file: src/middleware/auth.rs

#![allow(unused)]
fn main() {
use ferro::{Middleware, Request, Response, Next};
use async_trait::async_trait;

pub struct Auth;

#[async_trait]
impl Middleware for Auth {
    async fn handle(&self, request: Request, next: Next) -> Response {
        // TODO: Implement middleware logic
        next.run(request).await
    }
}
}

ferro make:action

Generate a single-action class for complex business logic.

ferro make:action CreateOrder
ferro make:action ProcessPayment

Generated file: src/actions/create_order.rs

#![allow(unused)]
fn main() {
use ferro::FrameworkError;

pub struct CreateOrder;

impl CreateOrder {
    pub async fn execute(&self) -> Result<(), FrameworkError> {
        // TODO: Implement action
        Ok(())
    }
}
}

ferro make:event

Generate an event struct for the event dispatcher.

ferro make:event UserRegistered
ferro make:event OrderPlaced

Generated file: src/events/user_registered.rs

#![allow(unused)]
fn main() {
use ferro_events::Event;

#[derive(Debug, Clone, Event)]
pub struct UserRegistered {
    pub user_id: i64,
}
}

ferro make:listener

Generate a listener that responds to events.

ferro make:listener SendWelcomeEmail
ferro make:listener NotifyAdmins

Generated file: src/listeners/send_welcome_email.rs

#![allow(unused)]
fn main() {
use ferro_events::{Listener, Event};
use async_trait::async_trait;

pub struct SendWelcomeEmail;

#[async_trait]
impl<E: Event + Send + Sync> Listener<E> for SendWelcomeEmail {
    async fn handle(&self, event: &E) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        // TODO: Implement listener
        Ok(())
    }
}
}

ferro make:job

Generate a background job for queue processing.

ferro make:job ProcessImage
ferro make:job SendEmail

Generated file: src/jobs/process_image.rs

#![allow(unused)]
fn main() {
use ferro_queue::{Job, JobContext};
use serde::{Deserialize, Serialize};
use async_trait::async_trait;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProcessImage {
    pub image_id: i64,
}

#[async_trait]
impl Job for ProcessImage {
    async fn handle(&self, ctx: &JobContext) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        // TODO: Implement job
        Ok(())
    }
}
}

ferro make:notification

Generate a multi-channel notification.

ferro make:notification OrderShipped
ferro make:notification InvoiceGenerated

Generated file: src/notifications/order_shipped.rs

#![allow(unused)]
fn main() {
use ferro_notifications::{Notification, Notifiable, Channel};

pub struct OrderShipped {
    pub order_id: i64,
}

impl Notification for OrderShipped {
    fn via(&self) -> Vec<Channel> {
        vec![Channel::Mail, Channel::Database]
    }
}
}

ferro make:migration

Generate a database migration file.

ferro make:migration create_posts_table
ferro make:migration add_status_to_orders

Generated file: src/migrations/m20240115_143052_create_posts_table.rs

#![allow(unused)]
fn main() {
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        // TODO: Implement migration
        Ok(())
    }

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        // TODO: Implement rollback
        Ok(())
    }
}
}

ferro make:inertia

Generate an Inertia.js page component with TypeScript types.

ferro make:inertia Dashboard
ferro make:inertia Users/Profile

Generated files:

  • frontend/src/pages/Dashboard.tsx
  • src/controllers/ (props struct)

ferro make:task

Generate a scheduled task.

ferro make:task CleanupExpiredSessions
ferro make:task SendDailyReport

Generated file: src/tasks/cleanup_expired_sessions.rs

#![allow(unused)]
fn main() {
use ferro::scheduling::{Task, Schedule};
use async_trait::async_trait;

pub struct CleanupExpiredSessions;

#[async_trait]
impl Task for CleanupExpiredSessions {
    fn schedule(&self) -> Schedule {
        Schedule::daily()
    }

    async fn handle(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        // TODO: Implement task
        Ok(())
    }
}
}

ferro make:seeder

Generate a database seeder.

ferro make:seeder UserSeeder
ferro make:seeder ProductSeeder

Generated file: src/seeders/user_seeder.rs

#![allow(unused)]
fn main() {
use ferro::database::Seeder;
use async_trait::async_trait;

pub struct UserSeeder;

#[async_trait]
impl Seeder for UserSeeder {
    async fn run(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
        // TODO: Seed data
        Ok(())
    }
}
}

ferro make:factory

Generate a model factory for testing.

ferro make:factory UserFactory
ferro make:factory PostFactory

Generated file: src/factories/user_factory.rs

#![allow(unused)]
fn main() {
use ferro::testing::Factory;
use fake::{Fake, Faker};

pub struct UserFactory;

impl Factory for UserFactory {
    type Model = user::Model;

    fn definition(&self) -> Self::Model {
        // TODO: Define factory
        todo!()
    }
}
}

ferro make:error

Generate a custom error type.

ferro make:error PaymentFailed
ferro make:error ValidationError

ferro make:scaffold

Generate a complete CRUD scaffold: model, migration, controller, and Inertia pages.

# Basic scaffold
ferro make:scaffold Post

# With field definitions
ferro make:scaffold Post title:string content:text published:bool

# Complex example
ferro make:scaffold Product name:string description:text price:float stock:integer

Field Types:

TypeRust TypeDatabase Type
stringStringVARCHAR(255)
textStringTEXT
integeri32INTEGER
biginti64BIGINT
floatf64DOUBLE
boolboolBOOLEAN
datetimeDateTimeTIMESTAMP
dateDateDATE
uuidUuidUUID

Generated Files:

src/
├── models/post.rs           # SeaORM entity
├── migrations/m*_create_posts_table.rs
├── controllers/post_controller.rs
frontend/src/pages/
├── posts/
│   ├── Index.tsx
│   ├── Show.tsx
│   ├── Create.tsx
│   └── Edit.tsx

Database Commands

ferro migrate

Run all pending migrations.

ferro migrate

ferro migrate:rollback

Rollback the last batch of migrations.

ferro migrate:rollback

ferro migrate:status

Show the status of all migrations.

ferro migrate:status

Output:

+------+------------------------------------------------+-------+
| Ran? | Migration                                       | Batch |
+------+------------------------------------------------+-------+
| Yes  | m20240101_000001_create_users_table            | 1     |
| Yes  | m20240101_000002_create_posts_table            | 1     |
| No   | m20240115_143052_add_status_to_posts           |       |
+------+------------------------------------------------+-------+

ferro migrate:fresh

Drop all tables and re-run all migrations.

ferro migrate:fresh

Warning: This is destructive and will delete all data.

ferro db:sync

Synchronize the database schema and generate entity files.

# Sync entities from existing database
ferro db:sync

# Run migrations first, then sync
ferro db:sync --migrate

Options:

OptionDescription
--migrateRun pending migrations before syncing

This command:

  1. Discovers the database schema (tables, columns, types)
  2. Generates SeaORM entity files in src/models/entities/
  3. Creates user-friendly model wrappers with the Ferro Model API

ferro db:query

Execute a raw SQL query against the database.

# Simple SELECT query
ferro db:query "SELECT * FROM users LIMIT 5"

# Query with conditions
ferro db:query "SELECT id, name, email FROM users WHERE active = true"

# Count query
ferro db:query "SELECT COUNT(*) FROM posts"

Features:

  • Reads DATABASE_URL from .env file
  • Supports SQLite and PostgreSQL databases
  • Displays results in a formatted table
  • Handles NULL values gracefully
  • Shows row count after results

Example Output:

+-----+-------+-------------------+
| 1   | Alice | alice@example.com |
| 2   | Bob   | bob@example.com   |
+-----+-------+-------------------+

→ 2 row(s)

Use Cases:

  • Quick data inspection during development
  • Debugging database state
  • Verifying migration results
  • Ad-hoc queries without external tools

Docker Commands

ferro docker:init

Initialize Docker configuration files.

ferro docker:init

Generated files:

  • Dockerfile
  • docker-compose.yml
  • .dockerignore

ferro docker:compose

Manage Docker Compose services.

# Start services
ferro docker:compose up

# Stop services
ferro docker:compose down

# Rebuild and start
ferro docker:compose up --build

Scheduling Commands

ferro schedule:run

Run scheduled tasks that are due.

ferro schedule:run

This executes all tasks whose schedule indicates they should run now. Typically called by a system cron job every minute:

* * * * * cd /path/to/project && ferro schedule:run >> /dev/null 2>&1

ferro schedule:work

Start the scheduler worker for continuous task execution.

ferro schedule:work

This runs in the foreground and checks for due tasks every minute. Useful for development or container deployments.

ferro schedule:list

Display all registered scheduled tasks.

ferro schedule:list

Output:

+---------------------------+-------------+-------------------+
| Task                      | Schedule    | Next Run          |
+---------------------------+-------------+-------------------+
| CleanupExpiredSessions    | Daily 00:00 | 2024-01-16 00:00  |
| SendDailyReport           | Daily 09:00 | 2024-01-15 09:00  |
| PruneOldNotifications     | Weekly Mon  | 2024-01-22 00:00  |
+---------------------------+-------------+-------------------+

Storage Commands

Create a symbolic link from public/storage to storage/app/public.

ferro storage:link

This allows publicly accessible files stored in storage/app/public to be served via the web server.

AI-Assisted Development

ferro mcp

Start the Model Context Protocol (MCP) server for AI-assisted development.

ferro mcp

The MCP server provides introspection tools that help AI assistants understand your Ferro application structure, including routes, models, controllers, and configuration.

ferro boost:install

Install AI development boost features.

ferro boost:install

This sets up configuration for enhanced AI-assisted development workflows.

Command Summary

CommandDescription
newCreate a new Ferro project
serveStart development server
generate-typesGenerate TypeScript types
make:controllerCreate a controller
make:middlewareCreate middleware
make:actionCreate an action class
make:eventCreate an event
make:listenerCreate a listener
make:jobCreate a background job
make:notificationCreate a notification
make:migrationCreate a migration
make:inertiaCreate an Inertia page
make:taskCreate a scheduled task
make:seederCreate a database seeder
make:factoryCreate a model factory
make:errorCreate a custom error
make:policyCreate an authorization policy
make:scaffoldCreate complete CRUD scaffold
migrateRun migrations
migrate:rollbackRollback migrations
migrate:statusShow migration status
migrate:freshFresh migrate (drop all)
db:syncSync database schema
db:queryExecute raw SQL query
docker:initInitialize Docker files
docker:composeManage Docker Compose
schedule:runRun due scheduled tasks
schedule:workStart scheduler worker
schedule:listList scheduled tasks
storage:linkCreate storage symlink
mcpStart MCP server
boost:installInstall AI boost features

Environment Variables

The CLI respects these environment variables:

VariableDescription
DATABASE_URLDatabase connection string
APP_ENVApplication environment (development, production)
RUST_LOGLogging level

Migration Guide: cancer to ferro

This guide documents the upgrade path from cancer to ferro (v2.0).

Overview

The framework has been renamed from "cancer" to "ferro" for crates.io publication. The API remains identical - only package names and imports have changed.

Migration Steps

1. Update Cargo.toml Dependencies

Replace all cancer dependencies with their ferro equivalents:

# Before
[dependencies]
cancer = "1.0"
cancer-events = "1.0"
cancer-queue = "1.0"
cancer-notifications = "1.0"
cancer-broadcast = "1.0"
cancer-storage = "1.0"
cancer-cache = "1.0"

# After
[dependencies]
ferro = "2.0"
ferro-events = "2.0"
ferro-queue = "2.0"
ferro-notifications = "2.0"
ferro-broadcast = "2.0"
ferro-storage = "2.0"
ferro-cache = "2.0"

2. Update Rust Imports

Replace cancer with ferro in all import statements:

#![allow(unused)]
fn main() {
// Before
use cancer::prelude::*;
use cancer_events::Event;
use cancer_queue::Job;

// After
use ferro::prelude::*;
use ferro_events::Event;
use ferro_queue::Job;
}

Use find-and-replace across your project:

  • use cancer:: to use ferro::
  • use cancer_ to use ferro_
  • cancer:: to ferro:: (in type paths)

3. Update CLI Commands

The CLI binary has been renamed:

# Before
cancer serve
cancer make:model User
cancer migrate

# After
ferro serve
ferro make:model User
ferro migrate

Update any scripts, CI configurations, or documentation that reference the CLI.

4. Update MCP Server Configuration

If using the MCP server for IDE integration:

// Before
{
  "mcpServers": {
    "cancer-mcp": {
      "command": "cancer-mcp",
      "args": ["serve"]
    }
  }
}

// After
{
  "mcpServers": {
    "ferro-mcp": {
      "command": "ferro-mcp",
      "args": ["serve"]
    }
  }
}

5. Update Environment Variables (Optional)

If you have custom environment variable prefixes, consider updating them for consistency:

# Optional - these still work but consider updating
CANCER_APP_KEY=... -> FERRO_APP_KEY=...

Breaking Changes

None. The v2.0 release contains only naming changes. All APIs, behaviors, and features remain identical to v1.x.

Verification

After migration, verify your setup:

# Check CLI installation
ferro --version

# Run tests
cargo test

# Start development server
ferro serve

Gradual Migration

For large projects, you can migrate gradually using Cargo aliases:

[dependencies]
# Temporary: use new crate with old import name
cancer = { package = "ferro", version = "2.0" }

This allows keeping existing imports while transitioning. Remove the alias once all code is updated.