Skip to content
loco
v1.0
★ 6.9k Get started

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

Terminal window
loco new --name tour_app --db sqlite --bg async --assets none
cd tour_app
Picking a database (--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:

Terminal window
$ cargo loco generate scaffold posts title:string! content:text --api

Now generate a comments model only (no controller yet — you’ll hand-write that one) that belongs to a post, using the references field type:

Terminal window
$ cargo loco generate model comments content:text post:references

Both 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:

migration/src/mYYYYMMDD_HHMMSS_comments.rs
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:

Terminal window
$ cargo loco generate controller comments --api

With 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 imports
use 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:

Terminal window
cargo loco start
Terminal window
$ 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:

Terminal window
$ cargo loco generate worker notifier
added: "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:

Terminal window
$ cargo loco generate task posts_report

Edit 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:

Terminal window
$ cargo loco task
posts_report [Print every post's title]
$ cargo loco task posts_report
- Tour post
done: 1 posts

Tasks 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

Terminal window
$ cargo loco routes

lists 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