This version is in beta. Some features may change before release.

What a plugin is

A `Plugin` is a self-contained, swappable unit that bundles models, routes, and logic into one builder call.

A Plugin is umbral's unit of composition: a self-contained, swappable bundle of everything one slice of an app contributes, registered with a single builder call.

One trait carries every contribution. The Plugin trait gathers models, routes, system checks, an on_ready startup hook, middleware via wrap_router, management commands via commands(), template directories, and embedded static files - all through one trait, into one .plugin(...) line. The built-in umbral-auth plugin is the canonical example: it ships a User model, password hashing helpers, a Plugin impl that registers the model under the "auth" namespace, and lives in its own crate under plugins/umbral-auth/. Structurally it's identical to anything you'd write yourself - there's no special-casing between built-in and third-party.

What a plugin contributes

A single Plugin impl can register any subset of the following surface:

You declareThrough
The plugin itselfApp::builder().plugin(MyPlugin)
Models (become migrations)pub struct ... with #[derive(Model)], returned from Plugin::models()
Migration filesplugins/<my-app>/migrations/<my-app>/
RoutesPlugin::routes() returning an axum Router
Startup workPlugin::on_ready(&ctx)
CLI subcommandsPlugin::commands(); umbral-auth contributes createsuperuser this way (see the Plugin trait page)
Table namesSnake-case of the struct by default; #[umbral(table = "...")] overrides it

Where plugins live

The convention is plugins/<name>/ at the repo root, as its own Cargo project. plugins/umbral-auth/ is the live example. The pattern matches what every built-in plugin will look like (sessions, tasks, admin, REST), and what a third-party cargo add umbral-blog would look like - there's no special-casing between "built-in" and "third-party".

A small project that doesn't yet need its own crate can register the Plugin impl right next to main.rs. Both shapes work; the crate split matters when you want the plugin to be reusable.

Why "plugin"

The name says what the unit is: a self-contained, swappable piece that extends the framework. Every capability - whether it ships with umbral or you wrote it - plugs in the same way, through the same trait, with the same .plugin(...) call. The plugin contract spec (docs/specs/02-plugin-contract.md) makes the requirement explicit from the architecture side: built-ins and third-parties have to be structurally identical, and "plugin" names that contract directly.

A complete plugin

Here's the whole shape in one file - a blog plugin that owns its own Post model, exposes a couple of routes that hit the ORM from the plugin code, customises REST via a ResourceConfig, and runs a one-shot seed on startup via on_ready. Drop this in plugins/blog/src/lib.rs and add a single .plugin(blog::BlogPlugin) line to your App::builder() chain.

Code
rust
//! The blog app. Owns the `Post` model + a couple of HTML pages.
 
use serde::{Deserialize, Serialize};
use serde_json::json;
use umbral::plugin::{AppContext, Plugin, PluginError};
use umbral::prelude::*;
use umbral::web::{Html, Json, Path, Router, StatusCode, get};
 
// --- Model -----------------------------------------------------------------
 
/// The plugin owns its model. `App::builder()` auto-discovers it
/// through the `Plugin::models()` hook below - no `.model::<Post>()`
/// line on the builder.
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, umbral::orm::Model)]
pub struct Post {
pub id: i64,
pub title: String,
pub body: String,
pub author_email: String,
}
 
// --- Route handlers -- accessing the ORM from inside the plugin ------------
 
/// HTML list view. `Post::objects()` reaches the ambient pool that
/// `App::build()` published - no `&Pool` arg through the handler chain,
/// no `State<DbPool>` extractor, no global look-up at the call site.
async fn list_posts() -> Result<Html<String>, (StatusCode, String)> {
let posts = Post::objects()
.order_by(post::ID.desc())
.limit(50)
.fetch()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
 
let mut html = String::from("<h1>Posts</h1><ul>");
for p in &posts {
html.push_str(&format!("<li>{} - {}</li>", p.id, p.title));
}
html.push_str("</ul>");
Ok(Html(html))
}
 
/// JSON detail view. Same ORM, different terminal - `.first()` rather than
/// `.fetch()` because we want one row or 404.
async fn post_detail(Path(id): Path<i64>) -> Result<Json<Post>, (StatusCode, String)> {
let found = Post::objects()
.filter(post::ID.eq(id))
.first()
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
found
.map(Json)
.ok_or((StatusCode::NOT_FOUND, format!("no post with id {id}")))
}
 
// --- REST customisation, bundled in the plugin ----------------------------
 
/// What the plugin's REST endpoints should look like. Lives WITH the
/// plugin so main.rs doesn't have to thread customisation per-table.
/// Register from main.rs via `.plugin(RestPlugin::default()
/// .resource(blog::rest_resource()))`.
pub fn rest_resource() -> umbral_rest::ResourceConfig {
umbral_rest::ResourceConfig::new("post")
// Hide the raw author email; show the domain only.
.transform("author_email", |v| {
let s = v.as_str().unwrap_or("");
match s.split_once('@') {
Some((_, d)) => json!(format!("***@{d}")),
None => v.clone(),
}
})
// Add a `summary` field derived from the body's first line.
.computed("summary", |row| {
let body = row.get("body").and_then(|v| v.as_str()).unwrap_or("");
let first_line = body.lines().next().unwrap_or("");
json!(first_line.chars().take(120).collect::<String>())
})
}
 
// --- The Plugin trait ------------------------------------------------------
 
pub struct BlogPlugin;
 
impl Plugin for BlogPlugin {
fn name(&self) -> &'static str {
"blog"
}
 
/// Auto-registration: every model this plugin owns goes here.
/// `App::builder()` walks `Plugin::models()` on every registered
/// plugin and feeds the results into the migration registry -
/// you do NOT also call `.model::<Post>()` from main.rs.
fn models(&self) -> Vec<umbral::migrate::ModelMeta> {
vec![umbral::migrate::ModelMeta::for_::<Post>()]
}
 
fn routes(&self) -> Router {
Router::new()
.route("/blog", get(list_posts))
.route("/blog/{id}", get(post_detail))
}
 
/// Runs after every other plugin has booted and the ambient pool
/// + model registry are live. The classic "seed on first run"
/// idiom - bulk_create is idempotent here because we check count
/// first; in production you'd guard this behind a feature flag
/// or move it to a separate `seed` management command.
fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> {
umbral::plugin::block_on_ready(async {
let count = Post::objects().count().await?;
if count == 0 {
Post::objects()
.bulk_create(vec![Post {
id: 0,
title: "Hello".into(),
body: "First post.".into(),
author_email: "alice@example.com".into(),
}])
.await?;
}
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(())
})
.map_err(|e| -> PluginError { Box::new(std::io::Error::other(e.to_string())) })?;
Ok(())
}
}

Wire it in main.rs:

Code
rust
let app = App::builder()
.settings(settings)
.database("default", pool)
.plugin(blog::BlogPlugin) // model + routes + on_ready
.plugin(
umbral_rest::RestPlugin::default()
.resource(blog::rest_resource()), // REST customisation
)
.build()?;

Four things that matter in this shape:

  1. One .plugin(...) line is enough. The plugin's models flow into the migration registry via Plugin::models(); you don't also write .model::<Post>(). App::builder() walks every registered plugin and collects its models in dependency order. .model::<T>() is for models that live in main.rs itself (no owning plugin) - when in doubt, put the model on the plugin.
  2. The ORM is ambient. Post::objects().fetch() works inside a route handler with no plumbing. Same call works inside on_ready, inside another plugin's handler, inside a CLI subcommand. The pool was published by App::build(); everything reads through the same OnceLock.
  3. REST customisation lives with the plugin. rest_resource() is a free function the plugin exposes. Adding the second plugin's REST customisation is one more line in main.rs, not a 30-line builder chain.
  4. on_ready bridges sync to async. The trait method is sync; sqlx is async; umbral::plugin::block_on_ready(...) is the canonical bridge. It works under a multi-thread #[tokio::main] runtime, under #[tokio::test]'s current-thread runtime, and with no ambient runtime at all - without panicking (a raw Handle::current().block_on would panic on the current-thread path). The umbral-rls plugin uses the same helper.

Next