The Tour
This tour moves faster than Your First App and assumes you’ve already installed loco and sea-orm-cli and generated at least one app. In one pass, you’ll touch the four pieces that make up almost every Loco feature: models, controllers, workers, and tasks. Each section links to the reference page that documents its full surface — this page only shows you the working path.
Set up
loco new --name tour_app --db sqlite --bg async --assets nonecd tour_app--db sqlite) also gives this app a ready-made authentication suite at /api/auth/* — that's covered separately in Build a small authenticated app. This tour ignores it and builds its own resources alongside it.
Models and controllers: a scaffold, and a plain model with a relation
Generate a posts scaffold — model, migration, entity, CRUD controller, and tests in one step:
$ cargo loco generate scaffold posts title:string! content:text --apiNow generate a comments model only (no controller yet — you’ll hand-write that one) that belongs to a post, using the references field type:
$ cargo loco generate model comments content:text post:referencesBoth commands write a migration, apply it immediately, and regenerate Sea-ORM entities — there’s no separate “now run the migration” step. Open the comments migration and you’ll see the relation as a plain data description, not hand-rolled SQL builder calls:
use loco_rs::schema::*;use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]pub struct Migration;
#[async_trait::async_trait]impl MigrationTrait for Migration { async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> { create_table(m, "comments", &[ ("id", ColType::PkAuto), ("content", ColType::TextNull), ], &[ ("post", ""), ] ).await }
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> { drop_table(m, "comments").await }}("post", "") means “add a foreign key to posts, and figure out the column name for me” — it becomes a required post_id column. That column is a 64-bit integer (BigInteger), matching the 64-bit auto-increment primary keys Loco 1.0 uses everywhere; see Schema & ColType DSL for the full column-type story.
Controllers: a generated one, and one you wire by hand
posts already has a full CRUD controller from its scaffold. comments only has a model so far — scaffold generates model and controller together, while model only builds the data layer. Reach for controller when you already have a model and just need a thin API on top:
$ cargo loco generate controller comments --apiWith no action names given, this stubs a single index action returning an empty body — it has no idea about your model’s columns. Replace src/controllers/comments.rs entirely with a small, purpose-built API: comments can only be added through a shallow route, and listed through a nested route under their post — never fetched singly, updated, or deleted:
#![allow(clippy::unused_async)]use loco_rs::prelude::*;use serde::{Deserialize, Serialize};
use crate::models::_entities::comments::{ActiveModel, Entity};
#[derive(Clone, Debug, Serialize, Deserialize)]pub struct Params { pub content: Option<String>, pub post_id: i64,}
impl Params { fn update(&self, item: &mut ActiveModel) { item.content = Set(self.content.clone()); item.post_id = Set(self.post_id); }}
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> { let mut item = ActiveModel { ..Default::default() }; params.update(&mut item); let item = item.insert(&ctx.db).await?; format::json(item)}
pub fn routes() -> Routes { Routes::new() .prefix("api/comments/") .add("/", post(add))}Now add the nested read on the posts side. In src/controllers/posts.rs (generated by the scaffold), add a route and a handler that loads a post’s comments through the relation Sea-ORM generated for you:
// add to the existing importsuse crate::models::_entities::comments;
pub async fn comments_for_post( Path(id): Path<i64>, State(ctx): State<AppContext>,) -> Result<Response> { let item = load_item(&ctx, id).await?; let comments = item.find_related(comments::Entity).all(&ctx.db).await?; format::json(comments)}
pub fn routes() -> Routes { Routes::new() .prefix("api/posts/") .add("/", get(list)) .add("/", post(add)) .add("{id}", get(get_one)) .add("{id}", delete(remove)) .add("{id}", put(update)) .add("{id}", patch(update)) .add("{id}/comments", get(comments_for_post)) // <- add this}Try it:
cargo loco start$ curl -X POST -H "Content-Type: application/json" -d '{"title":"Tour post","content":"hi"}' localhost:5150/api/posts{"id":1,...}
$ curl -X POST -H "Content-Type: application/json" -d '{"content":"nice post","post_id":1}' localhost:5150/api/comments{"id":1,...}
$ curl localhost:5150/api/posts/1/comments[{"id":1,"content":"nice post","post_id":1,...}]Workers: do something in the background
Generate a worker and see it register itself in src/app.rs:
$ cargo loco generate worker notifieradded: "src/workers/notifier.rs"injected: "src/workers/mod.rs"injected: "src/app.rs"The generated struct is always plainly named Worker, namespaced by its own module (workers::notifier::Worker) — that’s how you’ll refer to it. Edit src/workers/notifier.rs’s WorkerArgs and perform:
#[derive(Deserialize, Debug, Serialize)]pub struct WorkerArgs { pub post_id: i64,}
#[async_trait]impl BackgroundWorker<WorkerArgs> for Worker { fn build(ctx: &AppContext) -> Self { Self { ctx: ctx.clone() } } async fn perform(&self, args: WorkerArgs) -> Result<()> { println!("new comment/notification for post {}", args.post_id); Ok(()) }}Now enqueue it from posts::add in src/controllers/posts.rs, right after the post is inserted:
pub async fn add(State(ctx): State<AppContext>, Json(params): Json<Params>) -> Result<Response> { let mut item = ActiveModel { ..Default::default() }; params.update(&mut item); let item = item.insert(&ctx.db).await?;
crate::workers::notifier::Worker::perform_later( &ctx, crate::workers::notifier::WorkerArgs { post_id: item.id }, ) .await?;
format::json(item)}Because you generated this app with --bg async, workers.mode in config/development.yaml is BackgroundAsync — the job runs in-process, no extra --worker process needed. Restart with cargo loco start, POST a new post, and watch the worker’s println! appear in the same terminal.
Async-in-process is a development convenience. For a real deployment you’d typically move to a Redis-, Postgres-, or SQLite-backed queue and run workers as their own process(es) with cargo loco start --worker; see Add a background worker for the async-vs-queue tradeoff.
Tasks: a one-off, run from the CLI
Generate a task:
$ cargo loco generate task posts_reportEdit src/tasks/posts_report.rs:
use loco_rs::prelude::*;
use crate::models::_entities::posts;
pub struct PostsReport;#[async_trait]impl Task for PostsReport { fn task(&self) -> TaskInfo { TaskInfo { name: "posts_report".to_string(), detail: "Print every post's title".to_string(), } } async fn run(&self, app_context: &AppContext, _vars: &task::Vars) -> Result<()> { let posts = posts::Entity::find().all(&app_context.db).await?; for post in &posts { println!("- {}", post.title); } println!("done: {} posts", posts.len()); Ok(()) }}List and run it:
$ cargo loco taskposts_report [Print every post's title]
$ cargo loco task posts_report- Tour postdone: 1 postsTasks are compiled into your app binary and are environment-aware (cargo loco task posts_report -e production runs against production config) — a safer alternative to ad-hoc SQL against a live database. Tasks can also be triggered on a schedule; see Write a one-off task and Schedule recurring jobs.
See everything you just wired
$ cargo loco routeslists every route across every controller — the scaffolded posts CRUD, the hand-written comments routes, the nested posts/{id}/comments route you added by hand, and the /api/auth/* suite that came bundled with the database.
Next
- Build a small authenticated app — use that bundled auth suite for real: register, log in, and protect a route with a JWT.
- Add a model and Generators & field types — the full field-type mini-language you saw a slice of here.
- Schema & ColType DSL — every column type the migration DSL supports.
- Add a background worker, Write a one-off task, Schedule recurring jobs — the full picture behind this tour’s background-job and task sections.