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:
- Create a new directory
my-app - Initialize a Rust workspace
- Set up the frontend with React and TypeScript
- Configure the database
- 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 your first feature
- Directory Structure - Understand the project layout
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?
- Add validation to user creation
- Implement authentication
- Add middleware for protected routes
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 artifactsnode_modules/- Node.js dependenciesfrontend/dist/- Built frontend assets
Storage
The storage/ directory holds application files:
# Create public storage symlink
ferro storage:link
This links public/storage → storage/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:
| Method | URI | Handler |
|---|---|---|
| GET | /users | index |
| GET | /users/create | create |
| POST | /users | store |
| GET | /users/:id | show |
| GET | /users/:id/edit | edit |
| PUT | /users/:id | update |
| DELETE | /users/:id | destroy |
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:
- Create
src/middleware/auth.rswith a middleware stub - Update
src/middleware/mod.rsto 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 requestnext: A function to call the next middleware in the chain (or the route handler)
You can:
- Continue the chain: Call
next(request).awaitto 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:
- Global middleware (in registration order)
- Route group middleware
- Route-level middleware
- 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
| Feature | Usage |
|---|---|
| Create middleware | Implement Middleware trait |
| Global middleware | global_middleware!(MyMiddleware) in bootstrap.rs |
| Route middleware | .middleware(MyMiddleware) on route definition |
| Group middleware | .middleware(MyMiddleware) on route group |
| Short-circuit | Return Err(HttpResponse::...) without calling next() |
| Continue chain | Call 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:
- Automatic parameter extraction from path, query, body
- Dependency injection for services
- 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 headersInertia::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:
| Method | Handler | Description |
|---|---|---|
| GET /resources | index | List all |
| GET /resources/create | create | Show create form |
| POST /resources | store | Create new |
| GET /resources/:id | show | Show single |
| GET /resources/:id/edit | edit | Show edit form |
| PUT /resources/:id | update | Update |
| DELETE /resources/:id | destroy | Delete |
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 listenersSend + Sync + 'static- For async safetyEventtrait - 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
| Method | Description | Default |
|---|---|---|
handle(&self, event) | Process the event | Required |
name(&self) | Listener identifier | Type name |
should_stop_propagation(&self) | Stop other listeners | false |
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
Ergonomic API (Recommended)
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
- Keep events immutable - Events are data, not behavior
- Use descriptive names - Past tense for things that happened (OrderPlaced, UserRegistered)
- Include all needed data - Listeners shouldn't need to fetch additional data
- Queue heavy operations - Use
ShouldQueuefor emails, PDFs, external APIs - Handle failures gracefully - Listeners should not break on individual failures
- 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
| Method | Description | Default |
|---|---|---|
handle() | Job execution logic | Required |
name() | Job identifier for logging | Type name |
max_retries() | Retry attempts on failure | 3 |
retry_delay(attempt) | Delay before retry | 5 seconds |
timeout() | Maximum execution time | 60 seconds |
failed(error) | Called when all retries exhausted | Logs 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
- Keep jobs small - Jobs should do one thing well
- Make jobs idempotent - Safe to run multiple times
- Use appropriate timeouts - Set
timeout()based on expected duration - Handle failures gracefully - Implement
failed()for cleanup - Use dedicated queues - Separate critical jobs from bulk processing
- Monitor queue depth - Alert on growing backlogs
Environment Variables Reference
| Variable | Description | Default |
|---|---|---|
QUEUE_CONNECTION | "sync" or "redis" | sync |
QUEUE_DEFAULT | Default queue name | default |
QUEUE_PREFIX | Redis key prefix | ferro_queue |
QUEUE_BLOCK_TIMEOUT | Worker polling timeout (seconds) | 5 |
QUEUE_MAX_CONCURRENT | Max parallel jobs per worker | 10 |
REDIS_URL | Full Redis URL (overrides individual settings) | - |
REDIS_HOST | Redis server host | 127.0.0.1 |
REDIS_PORT | Redis server port | 6379 |
REDIS_PASSWORD | Redis password | - |
REDIS_DATABASE | Redis database number | 0 |
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
| Method | Description | Default |
|---|---|---|
via() | Channels to send through | Required |
to_mail() | Mail message content | None |
to_database() | Database message content | None |
to_slack() | Slack message content | None |
notification_type() | Type name for logging | Type 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
| Method | Description | Default |
|---|---|---|
route_notification_for(channel) | Get routing info per channel | Required |
notifiable_id() | Unique identifier | "unknown" |
notifiable_type() | Type name | Type name |
notify(notification) | Send a notification | Provided |
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
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
| Variable | Description | Default |
|---|---|---|
MAIL_HOST | SMTP server host | Required |
MAIL_PORT | SMTP server port | 587 |
MAIL_USERNAME | SMTP username | - |
MAIL_PASSWORD | SMTP password | - |
MAIL_FROM_ADDRESS | Default from email | Required |
MAIL_FROM_NAME | Default from name | - |
MAIL_ENCRYPTION | "tls" or "none" | tls |
SLACK_WEBHOOK_URL | Slack incoming webhook | - |
Best Practices
- Use descriptive notification names -
OrderShippednotNotification1 - Include all needed data - Pass everything the notification needs
- Keep notifications focused - One notification per event
- Use database for in-app - Combine with UI notification center
- Handle failures gracefully - Log errors, don't crash on send failures
- 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:
| Type | Prefix | Authorization | Use Case |
|---|---|---|---|
| Public | none | No | Public updates (news feed) |
| Private | private- | Yes | User-specific data |
| Presence | presence- | Yes | Track 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
| Variable | Description | Default |
|---|---|---|
BROADCAST_MAX_SUBSCRIBERS | Max subscribers per channel (0=unlimited) | 0 |
BROADCAST_MAX_CHANNELS | Max total channels (0=unlimited) | 0 |
BROADCAST_HEARTBEAT_INTERVAL | Heartbeat interval (seconds) | 30 |
BROADCAST_CLIENT_TIMEOUT | Client timeout (seconds) | 60 |
BROADCAST_ALLOW_CLIENT_EVENTS | Allow whisper messages | true |
Best Practices
- Use meaningful channel names -
orders.{id}notchannel1 - Authorize private data - Always use private channels for user-specific data
- Use presence for online status - Track who's viewing/editing
- Exclude senders when appropriate - Avoid echo effects
- Set reasonable limits - Prevent resource exhaustion with config limits
- 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
| Variable | Description | Default |
|---|---|---|
FILESYSTEM_DISK | Default disk name | local |
FILESYSTEM_LOCAL_ROOT | Local disk root path | ./storage |
FILESYSTEM_LOCAL_URL | Local disk URL base | - |
FILESYSTEM_PUBLIC_ROOT | Public disk root path | ./storage/public |
FILESYSTEM_PUBLIC_URL | Public disk URL base | /storage |
AWS_ACCESS_KEY_ID | S3 access key | - |
AWS_SECRET_ACCESS_KEY | S3 secret key | - |
AWS_DEFAULT_REGION | S3 region | us-east-1 |
AWS_BUCKET | S3 bucket name | - |
AWS_URL | S3 URL base | - |
Best Practices
- Use meaningful disk names -
public,uploads,backupsinstead ofdisk1 - Set appropriate visibility - Use private for sensitive files
- Organize files by date -
uploads/2024/01/file.pdfprevents directory bloat - Use the public disk for web assets - Images, CSS, JS that need URLs
- Use memory driver for tests - Fast and isolated testing
- Clean up temporary files - Delete files that are no longer needed
- 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
| Variable | Description | Default |
|---|---|---|
CACHE_DRIVER | Cache backend ("memory" or "redis") | memory |
CACHE_PREFIX | Key prefix for all entries | - |
CACHE_TTL | Default TTL in seconds | 3600 |
CACHE_MEMORY_CAPACITY | Max entries for memory store | 10000 |
REDIS_URL | Redis connection URL | redis://127.0.0.1:6379 |
Best Practices
- Use meaningful cache keys -
user:123:profilenotkey1 - Set appropriate TTLs - Balance freshness vs performance
- Use tags for related data - Makes invalidation easier
- Cache at the right level - Cache complete objects, not fragments
- Handle cache misses gracefully - Always have a fallback
- Use remember pattern - Cleaner code, less boilerplate
- Prefix keys in production - Avoid collisions between environments
- 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
- Route parameter
{user}is extracted from the URL - The parameter value is parsed as the model's primary key type
- The model is fetched from the database
- If not found, a 404 response is returned automatically
- 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
| Variable | Description | Default |
|---|---|---|
DATABASE_URL | Database connection URL | sqlite://./database.db |
DB_MAX_CONNECTIONS | Maximum pool connections | 10 |
DB_MIN_CONNECTIONS | Minimum pool connections | 1 |
DB_CONNECT_TIMEOUT | Connection timeout (seconds) | 30 |
DB_LOGGING | Enable SQL query logging | false |
Supported Databases
| Database | URL Format | Notes |
|---|---|---|
| PostgreSQL | postgres://user:pass@host:5432/db | Recommended for production |
| SQLite | sqlite://./path/to/db.sqlite | Great for development |
| SQLite (memory) | sqlite::memory: | For testing |
Best Practices
- Use migrations - Never modify database schema manually
- Implement Model traits - Get convenient static methods for free
- Use QueryBuilder - Cleaner API than raw SeaORM queries
- Leverage route binding - Automatic 404 handling for missing models
- Test with test_database! - Isolated, repeatable tests
- Use dependency injection - Cleaner code with
#[inject] db: Database - Enable logging in development -
DB_LOGGING=truefor debugging - 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
| Rule | Description | Example |
|---|---|---|
required() | Field must be present and not empty | required() |
required_if(field, value) | Required if another field equals value | required_if("type", "business") |
string() | Must be a string | string() |
integer() | Must be an integer | integer() |
numeric() | Must be numeric | numeric() |
boolean() | Must be a boolean | boolean() |
array() | Must be an array | array() |
min(n) | Minimum length/value | min(8) |
max(n) | Maximum length/value | max(255) |
between(min, max) | Value between min and max | between(1, 100) |
email() | Valid email format | email() |
url() | Valid URL format | url() |
regex(pattern) | Matches regex pattern | regex(r"^\d{5}$") |
alpha() | Only alphabetic characters | alpha() |
alpha_num() | Only alphanumeric | alpha_num() |
alpha_dash() | Alphanumeric, dashes, underscores | alpha_dash() |
date() | Valid date (YYYY-MM-DD) | date() |
confirmed() | Must match {field}_confirmation | confirmed() |
in_array(values) | Must be one of values | in_array(vec!["a", "b"]) |
not_in(values) | Must not be one of values | not_in(vec!["x", "y"]) |
different(field) | Must differ from field | different("old_email") |
same(field) | Must match field | same("password") |
nullable() | Can be null (stops if null) | nullable() |
accepted() | Must be "yes", "on", "1", true | accepted() |
Best Practices
- Use Form Requests for complex validation - Keeps controllers clean
- Provide custom messages - User-friendly error messages improve UX
- Use custom attributes - Replace technical field names with readable ones
- Validate early - Fail fast with clear error messages
- Use nullable() for optional fields - Prevents errors on missing optional data
- Create custom rules - Reuse validation logic across the application
- Return 422 status - Standard HTTP status for validation errors
- 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
- Use test_database! for isolation - Each test gets a fresh database
- Use factories for test data - Consistent, readable test setup
- Test both success and failure cases - Validate error handling
- Use meaningful test names -
test_user_cannot_access_admin_panel - Keep tests focused - One assertion concept per test
- Use Expect for readable assertions - Fluent API improves clarity
- Mock external services - Use TestContainer to isolate from APIs
- 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:
- Your controller returns an Inertia response with a component name and props
- On the first request, a full HTML page is rendered with the initial data
- On subsequent requests, only JSON is returned
- 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"
}
}
Links and Navigation
Inertia Link Component
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, ¤t_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-Inertiaheader - Uses 303 status for POST/PUT/PATCH/DELETE (forces GET on redirect)
- Includes proper
X-Inertia: trueresponse 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
- Use InertiaProps derive - Automatic camelCase conversion matches JavaScript conventions
- Save context before consuming request - Use
SavedInertiaContextfor validation flows - Share common data via middleware - Auth, flash, CSRF in
ShareInertiaData - Organize pages in folders -
Posts/Index.tsx,Posts/Show.tsxfor clarity - Use compile-time validation -
inertia_response!macro catches typos early - Handle version conflicts - Ensure smooth deployments with version checking
- Keep props minimal - Only send what the page needs
- Use partial reloads - Optimize updates by requesting only changed data
- 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:
| Option | Description |
|---|---|
--no-interaction | Skip prompts, use defaults |
--no-git | Don'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:
| Option | Default | Description |
|---|---|---|
--port | 3000 | Backend server port |
--frontend-port | 5173 | Frontend dev server port |
--backend-only | false | Run only the backend |
--frontend-only | false | Run only the frontend |
--skip-types | false | Don't regenerate TypeScript types |
What it does:
- Starts the Rust backend with
cargo watchfor hot reloading - Starts the Vite frontend dev server
- Watches Rust files to regenerate TypeScript types automatically
- 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:
| Option | Description |
|---|---|
--resource | Generate index, show, create, store, edit, update, destroy methods |
--api | Generate 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.tsxsrc/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:
| Type | Rust Type | Database Type |
|---|---|---|
string | String | VARCHAR(255) |
text | String | TEXT |
integer | i32 | INTEGER |
bigint | i64 | BIGINT |
float | f64 | DOUBLE |
bool | bool | BOOLEAN |
datetime | DateTime | TIMESTAMP |
date | Date | DATE |
uuid | Uuid | UUID |
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:
| Option | Description |
|---|---|
--migrate | Run pending migrations before syncing |
This command:
- Discovers the database schema (tables, columns, types)
- Generates SeaORM entity files in
src/models/entities/ - 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_URLfrom.envfile - 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:
Dockerfiledocker-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
ferro storage:link
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
| Command | Description |
|---|---|
new | Create a new Ferro project |
serve | Start development server |
generate-types | Generate TypeScript types |
make:controller | Create a controller |
make:middleware | Create middleware |
make:action | Create an action class |
make:event | Create an event |
make:listener | Create a listener |
make:job | Create a background job |
make:notification | Create a notification |
make:migration | Create a migration |
make:inertia | Create an Inertia page |
make:task | Create a scheduled task |
make:seeder | Create a database seeder |
make:factory | Create a model factory |
make:error | Create a custom error |
make:policy | Create an authorization policy |
make:scaffold | Create complete CRUD scaffold |
migrate | Run migrations |
migrate:rollback | Rollback migrations |
migrate:status | Show migration status |
migrate:fresh | Fresh migrate (drop all) |
db:sync | Sync database schema |
db:query | Execute raw SQL query |
docker:init | Initialize Docker files |
docker:compose | Manage Docker Compose |
schedule:run | Run due scheduled tasks |
schedule:work | Start scheduler worker |
schedule:list | List scheduled tasks |
storage:link | Create storage symlink |
mcp | Start MCP server |
boost:install | Install AI boost features |
Environment Variables
The CLI respects these environment variables:
| Variable | Description |
|---|---|
DATABASE_URL | Database connection string |
APP_ENV | Application environment (development, production) |
RUST_LOG | Logging 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::touse ferro::use cancer_touse ferro_cancer::toferro::(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.