ORM
Models follow Eloquent's API. Because Dart has no magic properties, each
model has a static ModelDefinition that describes its table, attributes
and relations; Database shows a complete model and the query
builder.
Definition#
final class User extends Model {
static final ModelDefinition<User> definition = ModelDefinition<User>(
table: 'users',
factory: User.new,
fillable: ['name', 'email', 'password'],
hidden: ['password', 'remember_token'],
casts: {'email_verified_at': Cast.dateTime, 'settings': Cast.json},
softDeletes: true,
relations: {
'posts': () => HasMany<Post>(() => Post.definition, foreignKey: 'user_id'),
'roles': () => BelongsToMany<Role>(
() => Role.definition,
pivotTable: 'role_user',
foreignPivotKey: 'user_id',
relatedPivotKey: 'role_id',
),
},
);
@override
ModelDefinition<User> get modelDefinition => User.definition;
}
The static is definition and the instance getter modelDefinition
because Dart forbids a static and an instance member with the same name.
Keep the explicit ModelDefinition<User> type and write relations as
functions: models that refer to each other would otherwise form an
inference cycle.
Other options: primaryKey, keyType (auto-increment, UUID, ULID or string),
connection, guarded, visible, appends, timestamps and the
createdAt / updatedAt / deletedAt column names, perPage, touches,
withRelations (Eloquent's $with) and morphClass (the Laravel class
name stored in polymorphic columns, App\Models\User by default).
Attributes#
Casts: Cast.integer, decimal, boolean, string, dateTime, date,
json, Cast.enumeration(MyEnum.values) and Cast.custom(...).
accessors: and mutators: replace Laravel's attribute methods, and
dartisan model:sync writes typed getters and setters from the table, so
user.email is a String checked by the compiler.
Model registry#
bootstrap/models.dart lists the application's models, the way the other
registries list migrations and jobs (Daravel discovers nothing by
reflection):
final List<ModelDefinition<Model>> models = <ModelDefinition<Model>>[
// <dartisan:entries>
User.definition,
Post.definition,
// </dartisan:entries>
];
createApp passes it to .withModels(models), and dartisan make:model
adds new models to it. dartisan model:show Post [--json] (Laravel 11's
model:show) prints the model's table, attributes with their type, cast,
fillable and hidden flags, and its relations; dartisan mcp:serve gives AI
agents the same data. Once the file exists, dartisan lint reports models
in lib/app/models that it leaves out.
Projects created before 1.3 can add the file by hand (copy the block above,
with one entry per model) and the .withModels(models) call; until then
everything else keeps working.
Relations#
HasOne, HasMany, BelongsTo, BelongsToMany (with withPivot and
withTimestamps), HasManyThrough, HasOneThrough, and the polymorphic MorphOne,
MorphMany, MorphTo, MorphToMany and MorphedByMany
(see Polymorphic relations).
final users = await User.query()
.with_(['posts', 'roles']) // eager loading; `with` is reserved in Dart
.withCount('posts')
.whereHas('posts', (q) => q.where('published', true))
.paginate();
final posts = users.items.first.related<List<Post>>('posts');
final drafts = await user.relatedQuery<Post>('posts').where('published', false).get();
await user.attach('roles', [1, 2]);
await user.sync('roles', [2, 3]);
await user.detach('roles', [3]);
load and loadMissing load relations onto models you already have.
Model.preventLazyLoading turns an unloaded relation into an error, which
catches N+1 queries in tests.
Nested existence queries#
whereHas, orWhereHas, has, whereDoesntHave, orWhereDoesntHave,
doesntHave and withWhereHas accept nested relations, at any depth. Each
level becomes an EXISTS inside the previous one, the constraint applies to
the last, and soft deletes and global scopes apply at every level:
// Users with at least one approved comment on one of their posts.
final users = await User.query()
.whereHas('posts.comments', (q) => q.where('approved', true))
.get();
A relation to the same model (Category with parent and children, users
following users) works too: the inner table gets an alias, as Laravel's
laravel_reserved_N, so whereHas('children') compares a child with its
outer parent rather than with itself.
Relation aggregates#
withCount, withSum, withAvg, withMin, withMax and withExists add
a column per relation, named as in Laravel (posts_count,
orders_sum_total, orders_exists); 'orders as paid_orders' picks the
name, and a map adds a constraint:
final users = await User.query()
.withCount('posts')
.withSum('orders', 'total')
.withMax({'orders': (ModelQuery<Model> q) => q.where('paid', true)}, 'total')
.withExists('orders')
.get();
*_exists reads as a bool. Sums and averages come back as the driver
returns them: numbers on SQLite, strings for DECIMAL/numeric results on
MySQL and PostgreSQL, as with Laravel's PDO.
Polymorphic relations#
A polymorphic relation lets one table belong to several models: comments on
posts and on videos, images of users and of products, tags shared by
everything. The child row stores the owner's type in {name}_type and its
key in {name}_id, exactly as Laravel stores them, so both frameworks can
read the same rows.
// Migration
schema.create('comments', (table) {
table.id();
table.morphs('commentable'); // commentable_type, commentable_id + index
table.text('body');
});
// Post (and Video, the same way)
'comments': () => MorphMany<Comment>(() => Comment.definition, morphName: 'commentable'),
'image': () => MorphOne<Image>(() => Image.definition, morphName: 'imageable'),
'tags': () => MorphToMany<Tag>(() => Tag.definition, morphName: 'taggable', relatedPivotKey: 'tag_id'),
// Comment: the owner, a Post or a Video
'commentable': () => MorphTo(types: [() => Post.definition, () => Video.definition]),
// Tag: the inverse of morphToMany
'posts': () => MorphedByMany<Post>(() => Post.definition, morphName: 'taggable', foreignPivotKey: 'tag_id'),
MorphMany/MorphOnework likeHasMany/HasOne, filtered on the owner's type: eager loading,relatedQuery,whereHas,withCountandcreate(parent, db, attributes), which fills both morph columns.MorphToloads each row's owner, one query per stored type when eager loading, and nested relations (with_(['commentable.author'])) load per type.associate(child, owner)anddissociate(child)set the columns;touches: ['commentable']updates the owner's timestamp.MorphToMany/MorphedByManywork likeBelongsToMany(attach,detach,sync,withPivot) with the type column on the pivot table ({name}sby default,taggableshere).whereHasMorph('commentable', [Post.definition], (q, type) => ...),whereDoesntHaveMorphand theirorWhere*forms query the owner. On aMorphTo,whereHasandwhereDoesntHavecheck every type the relation knows.
The stored type is the model's morphClass (App\Models\Post by default,
like Laravel's class name). Register aliases as in Laravel; rows that still
hold the class name keep resolving:
Relation.morphMap({'post': () => Post.definition, 'video': () => Video.definition});
Relation.enforceMorphMap({...}); // every polymorphic model must then have an alias
Dart has no reflection, so a MorphTo cannot turn a stored class name into
a model on its own: every possible owner must be listed in types: or in
the morph map. For the same reason, whereHas on a MorphTo checks the
listed types, where Laravel reads the distinct types from the table.
Scopes, soft deletes and events#
- Local scopes (
scopes:) are called withquery.scope('published');globalScopes:apply to every query. - With
softDeletes: true,delete()setsdeleted_at, queries skip deleted rows, andwithTrashed(),onlyTrashed(),restore()andforceDelete()work as in Laravel. - Observers (
observers:) receivecreating,created,updating,updated,saving,saved,deleting,deleted,restoring,restoredand the force-delete events; returningfalsefrom a*inghook cancels the operation.