Models & Relationships

  1. #[derive(Model)]
  2. created_at / updated_at: #[timestamps]
  3. deleted_at: #[soft_deletes]
  4. Model events: #[observe(...)]
  5. Relationships
    1. belongs_to / has_one
    2. has_many
    3. belongs_to_many (many-to-many, through a pivot table)
    4. has_many_through (a relationship through a third model)
  6. Eager loading (avoiding N+1)
  7. Next

#[derive(Model)]

use larust_support::orm::sqlx;
use larust_support::Model;

#[derive(Model, sqlx::FromRow)]
#[table("posts")]
#[belongs_to(User, foreign_key = "user_id")]
#[has_many(Comment, foreign_key = "post_id")]
pub struct Post {
    #[primary_key]
    pub id: i64,
    pub user_id: i64,
    pub title: String,
    pub content: String,
}

#[table("posts")] names the table explicitly - there’s no automatic pluralization-of-the-struct-name convention to fight with when it guesses wrong. #[primary_key] marks the id field (any i64 field; the column name itself is whatever you named the field). sqlx::FromRow is a real, separate derive you always add alongside Model - see Coming from Rust for why it can’t be folded into Model itself.

This generates, for every model:

pub struct NewPost {          // every field except the primary key
    pub user_id: i64,
    pub title: String,
    pub content: String,
}

impl Post {
    pub async fn create(data: NewPost) -> Result<Post, AppError>;
    pub async fn find(id: i64) -> Result<Option<Post>, AppError>;
    pub async fn update(id: i64, data: NewPost) -> Result<Post, AppError>;
    pub async fn delete(id: i64) -> Result<(), AppError>;
    pub fn query() -> QueryBuilder<Post>;
}

…plus a real FromRequestParts impl, which is what makes route model binding work - a handler parameter typed post: Post resolves by primary key from a {post} path segment automatically, 404ing if nothing matches.

let post = Post::create(NewPost {
    user_id: user.id,
    title: "Hello".to_string(),
    content: "...".to_string(),
}).await?;

let post = Post::update(post.id, NewPost { user_id: post.user_id, title: "Edited".to_string(), content: post.content }).await?;

Post::delete(post.id).await?;

Every field on NewPost mirrors Post’s own real, typed fields - there’s no dynamic attribute bag, so an update always carries every column’s full value (matching the reference app’s own convention of building a NewPost from the existing row’s other fields when only one is actually changing, as PostController::update/ProfileController both do).

created_at / updated_at: #[timestamps]

Laravel’s $table->timestamps() plus Eloquent’s default auto-touch behavior:

#[derive(Model, sqlx::FromRow)]
#[table("posts")]
#[timestamps]
pub struct Post {
    #[primary_key]
    pub id: i64,
    pub title: String,
    pub created_at: i64,
    pub updated_at: i64,
}
-- migration
CREATE TABLE posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    created_at INTEGER NOT NULL,
    updated_at INTEGER NOT NULL
);

Both columns are i64 - Unix seconds, the same convention every other framework-owned timestamp column already uses (larust-queue’s jobs table, larust-notifications’ own table), not a SQL DATETIME/ TIMESTAMP type. create() stamps both to the same value; update() re-stamps updated_at only, leaving created_at untouched. Neither field appears on the generated NewPost - exactly like the primary key, they’re never something you set yourself:

let post = Post::create(NewPost {
    title: "Hello".to_string(),
    // no created_at/updated_at here - #[timestamps] owns both
}).await?;
assert_eq!(post.created_at, post.updated_at); // just-created: identical

let post = Post::update(post.id, NewPost { title: "Edited".to_string() }).await?;
// post.updated_at just moved forward; post.created_at didn't

#[timestamps] is opt-in, not automatic on every model - this framework requires an explicit #[primary_key] even though id would be an equally obvious convention, so a model without the attribute is completely unaffected; add both fields and the attribute only where you actually want the behavior. xr convert already emits exactly this created_at INTEGER/updated_at INTEGER shape for a Laravel migration’s $table->timestamps() - add #[timestamps] to the matching #[derive(Model)] struct afterward to wire up the same auto-population Eloquent had.

deleted_at: #[soft_deletes]

Laravel’s SoftDeletes trait:

#[derive(Model, sqlx::FromRow)]
#[table("posts")]
#[soft_deletes]
pub struct Post {
    #[primary_key]
    pub id: i64,
    pub title: String,
    pub deleted_at: Option<i64>,
}
-- migration
CREATE TABLE posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    deleted_at INTEGER
);

deleted_at is Option<i64> (None until trashed, Unix seconds once set) and, like #[timestamps]’s two columns, never appears on the generated NewPost - it’s managed only through delete()/restore(), never set by hand. Once opted in:

Post::delete(post.id).await?;        // soft-deletes: sets deleted_at
Post::find(post.id).await?;          // None - normal reads exclude trashed rows
Post::all().await?;                  // excludes it too, same as query()

Post::find_with_trashed(post.id).await?;      // Some(post) - sees it anyway
Post::query().with_trashed().get().await?;    // every row, trashed or not
Post::query().only_trashed().get().await?;    // only trashed rows

Post::restore(post.id).await?;       // un-deletes it
Post::force_delete(post.id).await?;  // the real delete - bypasses deleted_at entirely
post.trashed();                      // true/false, on an already-loaded row

.with_trashed()/.only_trashed() only exist on a QueryBuilder<T> for a T that’s actually opted into #[soft_deletes] - calling either on a model that hasn’t is a compile error, not a silent no-op. A #[belongs_to_many(...)] relationship also respects the related model’s own #[soft_deletes] automatically - a trashed tag disappears from post.tags() the same way it would from Tag::query() directly. has_many/has_one/belongs_to already get this for free, since they route through the related model’s own query()/find().

Deliberately not included: deleting a row still never runs down() on anything, since migrations stay forward-only (see Migrations & the Query Builder) - #[soft_deletes] is about individual rows, not a rollback story for schema changes.

Model events: #[observe(...)]

Laravel’s model-event observers - a plain function, not a class:

#[derive(Model, sqlx::FromRow)]
#[table("posts")]
#[observe(creating = PostObserver::creating, created = PostObserver::created)]
pub struct Post {
    #[primary_key]
    pub id: i64,
    pub title: String,
}

struct PostObserver;

impl PostObserver {
    async fn creating(data: NewPost) -> Result<NewPost, AppError> {
        // Transform the data before it's inserted, or veto the write
        // entirely by returning `Err(...)`.
        Ok(NewPost { title: data.title.trim().to_string(), ..data })
    }

    async fn created(post: &Post) -> Result<(), AppError> {
        larust_support::event::dispatch(PostCreated { id: post.id });
        Ok(())
    }
}

Six hooks, each a plain path to an async fn with a fixed signature - creating/updating (before the write, can transform the data or veto by returning Err), created/updated/deleted (after, with the real row), deleting (before the real/soft delete). A typo’d hook name (#[observe(creatign = ...)]) or a hook with the wrong signature is a compile error naming exactly what’s wrong - not a silently-skipped default, since there’s no trait with optional methods behind this.

Fires only through create/update/delete (which includes model factories and Repository<T>, since both forward to these same methods) - never for a raw sqlx call, and never for pivot attach/ detach/sync. deleting/deleted don’t fire at all for an id that never existed - delete() keeps its existing “not an error if it didn’t exist” behavior unchanged.

Not yet supported: restoring/restored/force_deleting/ force_deleted for #[soft_deletes]’s own restore()/force_delete().

Relationships

Five kinds, declared as attributes on the struct. The first four each generate both a lazy per-instance accessor and a batch eager-loader; has_many_through (below) doesn’t have the eager-loader form yet.

belongs_to / has_one

#[belongs_to(User, foreign_key = "user_id")]   // this row's own user_id points at the related row
struct Post { ... }

#[has_one(Profile, foreign_key = "user_id")]   // the *related* row's foreign key points back at this one
struct User { ... }

Generates post.user() -> Result<Option<User>, AppError> / user.profile() -> Result<Option<Profile>, AppError> - the difference between the two is only which side owns the foreign key, exactly like Laravel’s own belongsTo/hasOne.

has_many

#[has_many(Comment, foreign_key = "post_id")]
struct Post { ... }

Generates post.comments() -> Result<Vec<Comment>, AppError>. The default method name is the related type’s name, pluralized and snake-cased (Comment → comments) - override it with method = "replies" if you need a different name (e.g. two has_many relations to the same related type on one model).

belongs_to_many (many-to-many, through a pivot table)

#[belongs_to_many(
    Tag,
    through = "post_tag",
    foreign_key = "post_id",
    related_pivot_key = "tag_id"
)]
struct Post { ... }

Generates four methods:

post.tags().await?;                 // Vec<Tag>, via an INNER JOIN through post_tag
post.attach_tag(tag_id).await?;     // insert one pivot row - a no-op if already attached, not a UNIQUE error
post.detach_tag(tag_id).await?;     // remove one pivot row
post.sync_tags(vec![id1, id2]).await?;  // replace the full set in one transaction

attach_*/detach_*/sync_* are named after the singular/plural related type name respectively - attach_tag/detach_tag/sync_tags for Tag, following from the same naming rule as has_many.

Deleting a row doesn’t automatically clean up its pivot rows - if a Post is deleted, its post_tag rows aren’t cascade-deleted for you unless your migration’s own REFERENCES ... ON DELETE CASCADE says so. The reference app’s PostController::destroy shows the explicit alternative: a plain DELETE FROM post_tag WHERE post_id = ? run before Post::delete(...).

If Tag is itself marked #[soft_deletes], post.tags() automatically excludes a trashed tag, the same as Tag::query() would.

has_many_through (a relationship through a third model)

#[has_many_through(Post, through = User, first_key = "country_id", second_key = "user_id")]
struct Country { ... }

Laravel’s hasManyThrough - Country has many Post, through User (every post belongs to a user, every user belongs to a country). Note through = User is a type, not a string, since the two keys are strings: first_key is the column on through (users) that points back at this model (users.country_id), second_key is the column on the related table (posts) that points at through (posts.user_id). Both required - no guessing from naming convention.

country.posts().await?;                       // Vec<Post>, across users
country.posts_query().latest(Post::ID).limit(5).get().await?;  // composes like any other query

posts_query() returns a plain QueryBuilder<Post> - chain .scope(...)/ .where_op(...)/.latest(...) etc. on it exactly like Post::query() itself, before running it with .posts()’s own default .get() or your own terminal method. No eager-loading (load_*) form yet - a real, documented gap, not an oversight.

Eager loading (avoiding N+1)

Every relationship’s batch loader takes a slice of rows and returns a lookup map, not a mutated collection - a deliberate difference from Laravel’s ::with(...), which attaches results back onto each model in place:

let posts = Post::query().paginate(20).await?;
let authors: HashMap<i64, User> = Post::load_user(&posts).await?;   // one query, not one per post

for post in &posts {
    let author = authors.get(&post.user_id);   // no query here
}

Why a map instead of mutation: a generated Post struct has no field to attach a User onto (adding one would mean every Post value carries an Option<User> slot whether or not it was ever loaded), and Rust’s own ownership rules make “mutate this vec of structs to attach borrowed related data” considerably more awkward than in a dynamically-typed language. A HashMap keyed by id, looked up per row in your own template or handler code, is the one that actually fits real Rust ergonomics.

load_* deduplicates ids before querying (several rows sharing one author only queries that author once) - the same discipline PostController::index’s own eager-loaded author list is built and verified against in this framework’s own test suite (2 queries for a whole page of posts + authors, not N+1, checked directly rather than assumed).

Next

Digging Deeper covers everything else an app tends to need - auth, mail, queues, and the reactive @wire/@live components that make Larust more than “Laravel’s directory layout with a Rust compiler bolted on.”