Skip to content

Seshat ORM

seshat gives you Seshat’s vocabulary — find, create, with_, whereHas, hasMany, scopes, soft deletes — on top of typed, immutable Dart classes. This page shows the shape of a model, then walks through the same features the Laravel documentation covers.

The complete Post definition below is intentionally explicit: one static definition describes how rows become immutable Dart objects and how those objects are safely written back.

Defining a Model

Laravel keeps a model’s configuration in static properties and a boot() method. Dart statics are not inherited, so seshat gathers that configuration in one ModelDefinition and the model points at it:

class Post extends Model<Post> {
Post({this.id, required this.userId, required this.title,
this.published = false, this.createdAt, this.updatedAt});
static final ModelDefinition<Post> def = ModelDefinition<Post>(
table: 'posts',
fromMap: (m) => Post(
id: m['id'] as int?,
userId: m['user_id'] as int,
title: m['title'] as String,
published: m['published'] as bool? ?? false,
createdAt: m['created_at'] as DateTime?,
updatedAt: m['updated_at'] as DateTime?,
),
fillable: ['user_id', 'title', 'published'],
casts: {'published': Cast.boolean, 'created_at': Cast.dateTime, 'updated_at': Cast.dateTime},
relations: (r) => r.belongsTo('user', User.def, foreignKey: 'user_id'),
);
@override
ModelDefinition<Post> get definition => def;
static QueryBuilder<Post> query() => def.query();
BelongsTo<User> user() => relation('user');
final int? id;
final int userId;
final String title;
final bool published;
final DateTime? createdAt;
final DateTime? updatedAt;
@override
Map<String, Object?> toMap() => {'id': id, 'user_id': userId, 'title': title,
'published': published, 'created_at': createdAt, 'updated_at': updatedAt};
}

ModelDefinition options: table, fromMap, name, primaryKey ('id'), incrementing, timestamps, createdAtColumn, updatedAtColumn, fillable, guarded, casts, relations, globalScopes, softDeletes, deletedAtColumn, events, connection.

Write the explicit ModelDefinition<Post> type on the static when models reference each other; the relations closure runs lazily, so User.def and Post.def can point at one another.

Casting

The database hands back driver types (SQLite stores booleans as integers and dates as text). casts converts them before fromMap runs and back again before writing:

casts: {
'active': Cast.boolean,
'created_at': Cast.dateTime,
'role': Cast.enumeration(Role.values),
'meta': Cast.json,
}

parseBool, parseInt, parseDouble, parseDateTime, parseEnum, parseJson are available when you want to cast by hand.

Mass Assignment

create() and update() accept a map and keep only fillable attributes (or everything not guarded). A model with neither list is totally guarded and throws MassAssignmentException, exactly like Laravel. forceCreate/forceUpdate/fill(force: true) skip the check for trusted data.

Retrieving Models

final users = await User.query().where('active', true).orderBy('name').get();
final user = await User.find(1); // null when missing
final user = await User.findOrFail(1); // ModelNotFoundException
final first = await User.query().where('email', email).first();
final page = await User.query().paginate(page: 2, perPage: 20);
await User.query().chunk(200, (users) { ... }); // pages, ordered by key
await for (final user in User.query().lazy()) { ... } // one row at a time

count, exists, sum, avg, min, max, pluck and value work as in Laravel.

Inserting and Updating

Models are immutable value objects. Methods that change the database return the persisted instance rather than mutating this:

final user = await User.create({'name': 'Ann', 'email': 'ann@example.com'});
final renamed = await user.update({'name': 'Anne'}); // writes only "name" and "updated_at"
final saved = await User(name: 'Bob', email: 'b@example.com').save();
final fresh = await renamed.refresh();

dirty, isDirty, original, exists, wasRecentlyCreated and key are available on every instance. firstOrCreate and updateOrCreate live on the builder.

Deleting

await user.delete();
await User.query().where('active', false).delete();

Soft Deletes

Turn them on with softDeletes: true on the definition and mix SoftDeletes<T> into the model for the instance helpers:

class User extends Model<User> with SoftDeletes<User> { ... }
await user.delete(); // sets deleted_at; the row stays
user.exists; // still true
await User.def.withTrashed().get();
await User.def.onlyTrashed().get();
final back = await user.restore();
await user.forceDelete();

A global scope hides trashed rows from every query, including relations and whereHas.

Scopes

Local scopes are extension methods; the typed builder makes them chain naturally:

extension UserScopes on QueryBuilder<User> {
QueryBuilder<User> active() => where('active', true);
QueryBuilder<User> recent() => orderBy('created_at', descending: true);
}
final users = await User.query().active().recent().get();

Global scopes are named on the definition so they are easy to find and easy to remove:

globalScopes: {'tenant': (q) => q.where('tenant_id', Tenant.current)},
...
await User.query().withoutGlobalScope('tenant').get();

Relationships

Relations are declared once on the definition, which is what lets with_() and whereHas() work without an instance. Each model exposes them through a one-line typed accessor:

relations: (r) => r
..hasOne('profile', Profile.def, foreignKey: 'user_id')
..hasMany('posts', Post.def, foreignKey: 'user_id')
..belongsToMany('roles', Role.def,
pivotTable: 'role_user', foreignPivotKey: 'user_id', relatedPivotKey: 'role_id',
pivotColumns: ['granted_by']),
HasOne<Profile> profile() => relation('profile');
HasMany<Post> posts() => relation('posts');
BelongsToMany<Role> roles() => relation('roles');

Querying a relation is explicit:

final posts = await user.posts().get();
final post = await user.posts().query().where('published', true).first();
await user.posts().create({'title': 'Hello'});
final owner = await post.user().get();

belongsToMany also has attach(ids, [pivotData]), detach([ids]) and sync(ids); loaded models expose their pivot row as model.pivot.

Eager Loading

final users = await User.with_(['posts', 'posts.comments', 'roles']).get();
for (final user in users) {
user.posts().value; // List<Post>, no query
user.profile().value; // Profile?
}
await User.query().withWhere('posts', (q) => q.where('published', true)).get();
await user.load(['posts']); // after the fact

Reading .value on a relation that was not loaded throws RelationNotLoadedException instead of quietly running a query. That is deliberate: N+1 problems cannot hide behind property access.

Querying Relationship Existence

await User.query().has('posts').get();
await User.query().whereHas('posts', (q) => q.where('published', true)).get();
await User.query().whereDoesntHave('profile').get();

Each of these compiles to an exists subquery correlated to the outer row. When the relation points back at its own table — a task with subtasks, a category with children — the subquery’s table would shadow the row it must correlate against, so it is aliased instead:

select * from "tasks" where exists (
select 1 from "tasks" as "__has_0" where "__has_0"."parent_id" = "tasks"."id")

The alias is applied only in that case, so every other query’s SQL is unchanged. Inside the constraint closure, write column names unqualified (q.where('done', true)) and they follow the alias. Writing the table name yourself (q.where('tasks.done', true)) reaches the outer table, which is almost never what you meant. __has_ is reserved as an alias prefix.

events: ModelEvents<User>(
creating: (u) => u.email.contains('@'), // false aborts
created: (u) async => Mail.welcome(u),
deleting: (u) => !u.isAdmin,
)

Vetoable: saving, creating, updating, deleting, restoring. After the fact: saved, created, updated, deleted, restored. Hooks cannot rewrite attributes, since models are immutable; do that in toMap() or at the call site.

Serialisation

toMap() is the database shape. toJson() adds loaded relations and converts DateTime to ISO-8601 and enums to their names, ready for jsonEncode. Paginator.toJson() produces Laravel’s data/total/per_page/current_page/last_page envelope.

For shaping a model into the JSON an endpoint returns — JsonResource, collections, the data/links/meta pagination envelope and ?include= — see API Resources.

What Is Not Ported

Polymorphic relations, hasManyThrough, accessors/mutators, $hidden/$appends, model factories, withCount, cursor pagination and pessimistic locks are not in this release. The reasons are in the package README.