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 missingfinal user = await User.findOrFail(1); // ModelNotFoundExceptionfinal 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 keyawait for (final user in User.query().lazy()) { ... } // one row at a timecount, 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 staysuser.exists; // still trueawait 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 factReading .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.