eloquent_flutter
Laravel Eloquent-style ORM for Flutter and Dart, built on top of Drift. Chainable queries, casts, soft deletes, dirty tracking, relationships, eager loading, and a Laravel-style schema / migrator — without giving up Drift's reactive streams or typesafe codegen.
Why eloquent_flutter?
Drift is one of the most capable SQLite ORMs in the Dart ecosystem, but its day-to-day API is verbose:
final users = await (db.select(db.users)
..where((u) => u.active.equals(true) & u.age.isBiggerThanValue(18))
..orderBy([(u) => OrderingTerm(expression: u.name)]))
.get();
Eloquent-style code is shorter, more discoverable, and reads like the SQL it generates:
final users = await User.where('active', true).where('age', '>', 18).orderBy('name').get();
eloquent_flutter is a thin wrapper on top of Drift — not a
replacement. You keep your existing @DriftDatabase and the
generated row classes. The package adds chainable queries, casts,
soft deletes, dirty tracking, relationships, eager loading, observers,
pagination, auto-timestamps, and a Laravel-style Schema / Migrator
for declarative DDL.
Table of contents
- Features
- Performance
- Install
- Quick start
- Usage guide
- Database setup
- The registry pattern
- Defining a model
- Creating records
- Reading records
- Updating and deleting
- Chainable queries
- Operators
- Aggregates
- Casts
- Soft deletes
- Dirty tracking
- Reactive streams
- Pagination
- Transactions
- Raw SQL
- Relationships
- Eager loading
- Lifecycle observers
- Auto timestamps
- Schema: declaring tables
- Migrations: evolving the schema
- API reference
- Limitations
- Roadmap
- Contributing
- Maintainer
- License
Features
- Chainable
QueryBuilder<T, D>that also implements Drift'sSelectable<T>—.get(),.watch(),.getSingleOrNull()work for free. - Forwarding statics —
User.all(),User.find(id),User.create(map),User.where(...). - Rich operator set —
=,!=,<,<=,>,>=,LIKE,NOT LIKE,IN,NOT IN,IS NULL,IS NOT NULL,BETWEEN. - Aggregates —
count/min/max/avg/sumonQueryBuilder, pluswithCount/withSum/withAvg/withMin/withMaxfor correlated subquery columns. - Casts — per-column
$castsregistry withint,double,string,bool,date,datetime,json,array. Read viagetAttribute, write viasetAttribute; applied automatically onModelQuery.create(map). - Soft deletes — opt-in
with SoftDeletes<...>mixin. Tables with adeleted_atcolumn auto-exclude trashed rows. Override withwithTrashed()/onlyTrashed(). - Dirty tracking —
isDirty,isClean,wasChanged,getOriginal,$original,$dirty,$changes. Snapshot taken onsave(),update(),refresh(). - Reactive streams —
watch(),watchSingle(),watchSingleOrNull()re-emit on any write to the underlying table. - Transactions —
Eloquent.transaction(() async { ... }). Nested calls reuse the outer transaction. - Raw SQL escape hatch —
Eloquent.raw(sql, [vars]),Eloquent.rawSelect(sql, [vars]). Always use?placeholders. - Relationships —
HasMany,HasOne,BelongsTo,BelongsToManywithattach/detach/sync. - Eager loading —
User.query().with_(['posts', 'profile']).get()batches related fetches into one query per relation. - Lifecycle observers —
creating/created/updating/updated/deleting/deleted. Cancelable by returningfalseon the*inghook.saveQuietly/deleteQuietly/Model.withoutEvents(...)skip dispatch. - Pagination —
Paginator<T>withdata,currentPage,lastPage,total,hasMore,nextPage(),previousPage(). - Auto timestamps — opt-in
WithTimestampsmixin. - Schema + Migrations — Laravel-style
Schema.create()/Schema.table()with a Blueprint DSL, plus aMigratorthat tracks applied migrations in an_migrationsledger and supportsup()/down()rollbacks.
Performance
benchmark/eloquent_vs_drift.dart is the reference. It runs INSERT /
FIND / WHERE / ALL / COUNT / SAVE head-to-head against raw drift on a
1k-row SQLite-in-memory table. The wrapper pays for two things it
cannot avoid: one extra SELECT round-trip to populate
auto-incremented / defaulted columns after INSERT, and per-instance
state (casts, dirty tracking, snapshot). Per-row construction cost is
~50ns — about 5% of drift's own round-trip.
| Operation | Overhead vs raw drift |
|---|---|
count() (no predicates) |
~13% |
all() (1k rows) |
~9% |
where(...).orderBy().limit().get() |
~50% |
find(id) |
~63% |
create(map) (vs insert+read-back) |
~175% |
new Model(...).save() (vs bare insert) |
~262% (lower bound) |
Run it with:
dart run benchmark/eloquent_vs_drift.dart
Install
dependencies:
drift: ^2.18.0
eloquent_flutter: ^0.1.0
Or for the latest unreleased:
dependencies:
eloquent_flutter:
git: https://github.com/ubxty/eloquent_flutter.git
Then dart pub get. The example app uses Drift's
NativeDatabase.memory(); for production, point AppDatabase at
NativeDatabase(file) (or flutter for cross-platform).
Quick start
// 1. Open Drift, register with Eloquent
final db = AppDatabase(NativeDatabase.memory());
Eloquent.init(db);
AppRegistry.init(db);
// 2. Apply pending migrations
await migrate();
// 3. Use models like Laravel
final alice = await User.create({
'email': 'alice@example.com',
'name': 'Alice',
});
final active = await User.where('active', true).orderBy('name').get();
final page = await User.where('active', true).paginate(page: 1, perPage: 20);
final posts = await alice.posts().get();
The full example in example/ runs through every feature.
Clone, dart pub get, dart run build_runner build,
dart run example/lib/main.dart.
Usage guide
Database setup
Declare your Drift tables as you normally would:
// lib/database.dart
import 'package:drift/drift.dart';
part 'database.g.dart';
@DataClassName('UserRow')
class Users extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get email => text().unique()();
TextColumn get name => text()();
BoolColumn get active => boolean().withDefault(const Constant(true))();
DateTimeColumn get createdAt => dateTime().nullable()();
DateTimeColumn get updatedAt => dateTime().nullable()();
}
@DriftDatabase(tables: [Users, Posts, Profiles, Roles, RoleUsers])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
// Schema is owned by eloquent's `Migrator`, not by Drift's
// MigrationStrategy. Keep the strategy as a no-op so the two don't
// fight each other.
@override
int get schemaVersion => 1;
@override
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) async {},
onUpgrade: (m, from, to) async {},
);
}
Run dart run build_runner build to generate database.g.dart.
The Drift schema here exists only for type-safe row access
(UserRow, PostRow, etc.) and codegen-driven Selectable plumbing.
The actual DDL is written by your migrations (see
Schema: declaring tables).
The registry pattern
Drift exposes each table as an instance getter on the database
(db.users, db.posts, …). Models need a static handle to their
table — the simplest pattern is a singleton registry:
// lib/registry.dart
import 'package:drift/drift.dart';
import 'database.dart';
class AppRegistry {
AppRegistry._();
static AppDatabase? _db;
static void init(AppDatabase db) => _db = db;
static TableInfo<Users, UserRow> get users => _instance.users;
static TableInfo<Posts, PostRow> get posts => _instance.posts;
static TableInfo<Profiles, ProfileRow> get profiles => _instance.profiles;
static TableInfo<Roles, RoleRow> get roles => _instance.roles;
static AppDatabase get _instance =>
_db ?? (throw StateError('AppRegistry.init(db) must be called first.'));
}
Call AppRegistry.init(db) immediately after Eloquent.init(db).
Anywhere in the app you can read AppRegistry.users.
Why a registry? Drift's
TableInfo<T, D>is parameterized over the row type. The wrapper needs the same instance everywhere aUseris constructed; a static accessor avoids threading the database through every callsite.
Defining a model
// lib/models/user.dart
import 'package:drift/drift.dart' show TableInfo;
import 'package:eloquent_flutter/eloquent_flutter.dart';
import '../database.dart';
import '../registry.dart';
import 'post.dart';
import 'profile.dart';
import 'role.dart';
class User extends Model<User, UserRow>
with SoftDeletes<User, UserRow>, WithTimestamps {
User(super.data);
@override
TableInfo<Users, UserRow> get $table => AppRegistry.users;
@override
User $wrap(UserRow data) => User(data);
@override
Map<String, dynamic> toMap() => {
'id': $data.id,
'email': $data.email,
'name': $data.name,
'active': $data.active,
'created_at': $data.createdAt,
'updated_at': $data.updatedAt,
};
// Per-column cast registry. Values round-trip through these on
// getAttribute / setAttribute and on ModelQuery.create(map).
@override
Map<String, String> get $casts => const {
'created_at': 'datetime',
'updated_at': 'datetime',
'deleted_at': 'datetime',
};
// Relationships.
HasMany<Post, PostRow> posts() => HasMany<Post, PostRow>(
local: this,
relatedTable: AppRegistry.posts,
foreignKey: 'user_id',
creator: Post.new,
);
HasOne<Profile, ProfileRow> profile() => HasOne<Profile, ProfileRow>(
local: this,
relatedTable: AppRegistry.profiles,
foreignKey: 'user_id',
creator: Profile.new,
);
BelongsToMany<Role, RoleRow> roles() => BelongsToMany<Role, RoleRow>(
local: this,
relatedTable: AppRegistry.roles,
creator: Role.new,
pivotTable: 'role_users',
foreignPivotKey: 'user_id',
relatedPivotKey: 'role_id',
);
// Eager-loading registry.
@override
Map<String, Relationship<dynamic>> get $relations => {
'posts': posts(),
'profile': profile(),
'roles': roles(),
};
// Lifecycle hooks.
@override
ObserverSet get $observers => ObserverSet(
creating: (u) => (u.toMap()['email'] as String).contains('@'),
created: (u) => print('User created: id=${u.toMap()["id"]}'),
);
// Forwarding statics — copy/paste per model.
static final _q = ModelQuery<User, UserRow>(
table: AppRegistry.users,
creator: User.new,
primaryKey: 'id',
);
static Future<List<User>> all() => _q.all();
static Future<User?> find(Object id) => _q.find(id);
static Future<User> findOrFail(Object id) => _q.findOrFail(id);
static Future<User?> first({String? orderBy}) => _q.first(orderBy: orderBy);
static Future<int> count() => _q.count();
static Future<bool> exists() => _q.exists();
static Stream<List<User>> watch() => _q.watch();
static Future<User> create(Map<String, dynamic> v) => _q.create(v);
static QueryBuilder<User, UserRow> where(
String c, [Object? v, String op = '=']) =>
_q.where(c, v, op);
static QueryBuilder<User, UserRow> query() => _q.query();
}
Model<T, D> takes two type parameters: T is the model class
itself (used for covariant returns) and D is the Drift-generated
row class. $table, $wrap, and toMap() are abstract — every
model must implement them.
Creating records
final user = await User.create({
'email': 'alice@example.com',
'name': 'Alice',
});
create() inserts the row and fires the created observer. To
cancel before insert, build the instance yourself and use save():
final u = User(UserRow(email: 'a@b', name: 'A', active: true, /* … */));
try {
await u.save(); // fires `creating`, then INSERT, then `created`
} on OperationCancelledException {
// `creating` returned false
}
save() upserts: if the row has no primary key yet it inserts,
otherwise it updates. refresh() re-reads the row from the database.
Reading records
final all = await User.all();
final one = await User.find(1);
final required = await User.findOrFail(1); // throws ModelNotFoundException
final firstActive = await User.where('active', true).first();
final count = await User.count();
final hasAny = await User.exists();
Updating and deleting
// Instance methods — fire `updating` / `updated`, `deleting` / `deleted`
await user.update({'name': 'New name'});
await user.delete();
await user.refresh();
// Mass operations on a query
final affected = await User.where('active', false).update({'active': true});
final deleted = await User.where('id', [1, 2, 3], 'in').delete();
For silent operations (no observer dispatch), use saveQuietly,
deleteQuietly, or wrap a batch in Model.withoutEvents(...):
await user.saveQuietly();
await Model.withoutEvents(() async {
for (final row in seedRows) await User.create(row);
});
Chainable queries
final adults = await User
.where('active', true)
.where('age', '>', 18)
.whereNotNull('verified_at')
.orderByDesc('created_at')
.limit(20)
.offset(40)
.get();
Available chainable methods on QueryBuilder<T, D>:
| Method | Description |
|---|---|
where(c, [v, op]) |
Add a predicate. op defaults to =. |
orWhere(c, [v, op]) |
OR-join with the previous predicate. |
whereIn(c, list) / whereNotIn(c, list) |
IN / NOT IN. |
whereNull(c) / whereNotNull(c) |
NULL checks. |
whereBetween(c, a, b) |
Inclusive BETWEEN. |
whereRaw(expr) |
Drop down to a Drift Expression<bool>. |
orderBy(c, {descending}) / orderByDesc(c) |
Sort. |
limit(n) / offset(k) |
Pagination slicing. |
with_(name) / with_([names]) |
Eager-load named relations. |
withCount(name) / withSum(name, col) / withAvg(name, col) / withMin(name, col) / withMax(name, col) |
Correlated aggregate subquery columns. |
has(name, [op, n]) / whereHas(name, [cb]) / doesntHave(name) / whereDoesntHave(name, [cb]) (and orHas / orWhereHas / orDoesntHave / orWhereDoesntHave) |
Relationship existence predicates. |
withTrashed() / onlyTrashed() / withoutTrashed() |
Soft-delete filter overrides. |
first() |
First row, or null. |
count() / exists() |
Aggregates. |
min(c) / max(c) / avg(c) / sum(c) |
Column aggregates. |
pluck(c) / pluck(c, key) |
Single-column list or keyed map. |
value(c) |
First row's column value, or null. |
sole() |
Assert exactly one row matches. |
update(map) |
Mass update matching rows. |
delete() |
Mass delete matching rows. |
paginate({page, perPage}) |
Returns a Paginator<T>. |
QueryBuilder<T, D> implements Selectable<T>, so all of Drift's
terminal methods work too: .get(), .watch(), .getSingleOrNull(),
.watchSingleOrNull(), plus map / asyncMap for custom
transformations.
Operators
The string-based where() accepts the full Eloquent operator set:
=, ==, !=, <>, >, >=, <, <=,
like, not like,
in, not in,
is null, is not null,
between
IS NULL and IS NOT NULL ignore the value argument:
User.where('verified_at', null, 'is null'); // verified_at IS NULL
User.where('verified_at', null, 'is not null'); // verified_at IS NOT NULL
For type-mismatched operators the package throws
InvalidArgumentException (e.g. LIKE on an integer column, > on
a boolean column). For anything beyond the operator string set, drop
to whereRaw with a typed Drift expression:
import 'package:drift/drift.dart' show OrderingTerm;
User.query().whereRaw((u) => u.email.like('%@example.com')).get();
Aggregates
count, exists, min, max, avg, sum all honor the
soft-delete filter on tables that have a deleted_at column. Use
withTrashed() / onlyTrashed() to flip it.
final total = await User.count();
final oldest = await User.max('age');
final avgAge = await User.avg('age');
final hasAny = await User.exists();
final admins = await User.where('role', 'admin').count();
Attach a correlated count to each parent row:
final users = await User.query().withCount('posts').get();
for (final u in users) {
print('${u.toMap()["name"]}: ${u.getLoaded("posts_count")} posts');
}
Casts
Declare a per-column cast registry on the model:
@override
Map<String, String> get $casts => {
'age': 'int',
'is_admin': 'bool',
'meta': 'json',
'birthday': 'date',
'created_at': 'datetime',
};
Supported types (CastType):
| Constant | Cast type | Reads | Writes |
|---|---|---|---|
CastType.integer |
int |
int from int / String / bool / double |
parses the user value to int |
CastType.double_ |
double |
double from double / int / String |
parses to double |
CastType.string |
String |
String via toString() |
unchanged |
CastType.boolean |
bool |
bool from bool / int / String |
normalized to bool |
CastType.date |
DateTime |
date-only DateTime (midnight) |
date-only DateTime |
CastType.dateTime |
DateTime |
DateTime from DateTime / String / epoch seconds |
DateTime |
CastType.json |
Map<String, dynamic> |
JSON object via jsonDecode |
JSON string via jsonEncode |
CastType.array |
List<dynamic> |
JSON array via jsonDecode |
JSON string via jsonEncode |
Read with getAttribute(key), write with setAttribute(key, value):
final age = user.getAttribute('age') as int; // cast on read
user.setAttribute('meta', {'theme': 'dark'}); // encoded to JSON
await user.save(); // lands as a JSON string
ModelQuery.create(map) applies the casts before insert, so the
value in the database is always the cast's storage type, not
whatever the user typed.
Soft deletes
Add the SoftDeletes mixin to a model whose table has a nullable
deleted_at column. By default, every read on the table excludes
trashed rows:
class User extends Model<User, UserRow> with SoftDeletes<User, UserRow> {
// ...
}
await user.delete(); // sets deleted_at = now()
await user.refresh();
print(user.trashed); // true
await user.restore(); // clears deleted_at
await User.all(); // live rows only
await User.withTrashed().get(); // live + trashed
await User.onlyTrashed().get(); // trashed only
delete() is reversible (restore() clears the column). To remove
the row permanently, use forceDelete(). The min / max / avg /
sum / count aggregates also respect the filter.
Dirty tracking
Every Model tracks dirty columns after writes through
setAttribute or update({...}). The snapshot is taken by
save(), update(), and refresh().
final u = await User.find(1);
u.setAttribute('name', 'New name');
u.isDirty(); // true
u.isDirty('name'); // true
u.isClean('email'); // true (no pending write)
await u.save();
u.isDirty(); // false (snapshot taken)
u.wasChanged(); // true (writes happened during this lifecycle)
u.wasChanged('name'); // true
await u.refresh();
u.wasChanged(); // false (next snapshot)
u.$original; // { ... row at last snapshot ... }
u.getOriginal('name'); // 'New name' (the value at snapshot)
setAttribute writes go through the inverse of any registered cast,
so a '42' typed as 'int' becomes 42 in $pending and in the
column on save().
Reactive streams
watch() re-emits whenever the table is written to. This pairs
with StreamBuilder in Flutter:
final users$ = User.watch(); // Stream<List<User>>
final one$ = User.where('id', 1).watchSingleOrNull();
StreamBuilder<List<User>>(
stream: users$,
builder: (ctx, snap) => ListView(children: /* snap.data!.map(...) */),
);
Streams are powered by Drift's reactive query engine, so any write
that touches the underlying table — including raw SQL via
customInsert, customUpdate, customWriteReturning — triggers a
re-emission.
Pagination
final page = await User
.where('active', true)
.paginate(page: 2, perPage: 20);
page.data; // List<User> on this page
page.currentPage; // 2
page.lastPage; // 5
page.total; // 100
page.perPage; // 20
page.hasMore; // true
page.hasPrevious; // true
final next = await page.nextPage();
final prev = await page.previousPage();
Transactions
await Eloquent.transaction(() async {
final u = await User.create({'email': 'x@y', 'name': 'x'});
await Post.create({'user_id': u.toMap()['id'], 'title': 'first'});
if (bad) throw StateError('rolled back'); // everything unwinds
});
Nested transaction calls reuse the outer transaction in Drift, so
this composes naturally.
Raw SQL
When you need to escape the wrapper:
// DML / DDL — returns nothing
await Eloquent.raw(
'UPDATE users SET active = ? WHERE last_login < ?',
[false, DateTime.now().subtract(Duration(days: 90))],
);
// Ad-hoc SELECT
final rows = await Eloquent.rawSelect(
'SELECT email, COUNT(*) AS n FROM users GROUP BY email HAVING n > 1',
);
// rows: List<Map<String, Object?>>
For reactive SELECTs, use Drift's native customSelect directly:
final stream = Eloquent.db
.customSelect('SELECT * FROM users', readsFrom: {Eloquent.db.users})
.watch();
Always bind user input. Use
?placeholders and thevariablesparameter — never interpolate values into the SQL string.Eloquent.rawSelectacceptsObject?sonullbinds as SQLNULL.
Relationships
Declare relationships on the model as instance methods:
class User extends Model<User, UserRow> {
HasMany<Post, PostRow> posts() => HasMany<Post, PostRow>(
local: this, relatedTable: AppRegistry.posts,
foreignKey: 'user_id', creator: Post.new,
);
HasOne<Profile, ProfileRow> profile() => HasOne<Profile, ProfileRow>(
local: this, relatedTable: AppRegistry.profiles,
foreignKey: 'user_id', creator: Profile.new,
);
}
class Post extends Model<Post, PostRow> {
BelongsTo<User, UserRow> user() => BelongsTo<User, UserRow>(
local: this, relatedTable: AppRegistry.users,
foreignKey: 'user_id', creator: User.new,
);
}
Then call them like Laravel:
final posts = await alice.posts().get();
final profile = await alice.profile().get();
final owner = await firstPost.user().get();
final liveUser = firstPost.user().watch(); // Stream<User?>
HasMany and HasOne also expose create(map) / createOrFail(map)
that auto-fill the foreign key from the parent:
final p = await alice.posts().create({'title': 'new', 'body': '...'});
// p.user_id == alice.toMap()['id']
BelongsToMany adds pivot-table ergonomics:
await alice.roles().attach(adminId);
await alice.roles().detach(editorId);
await alice.roles().sync([adminId]); // full replace
final roles = await alice.roles().get();
Eager loading
Eager loading batches the related fetches into one query per
relation, so User.query().with_(['posts', 'profile']).get() runs
three queries instead of 1 + N.
final users = await User.query().with_(['posts', 'profile']).get();
for (final u in users) {
print(u.toMap()['name']);
if (u.isLoaded('posts')) {
for (final p in u.getLoaded<List<Post>>('posts')) print(' - ${p.toMap()['title']}');
}
if (u.isLoaded('profile')) {
print(' bio: ${u.getLoaded<Profile>('profile').toMap()['bio']}');
}
}
The string keys must match $relations on the model. An unknown key
throws RelationNotFoundException listing the available relations.
Lifecycle observers
Two styles, both supported, with the registry form taking precedence if both are declared:
// Laravel-style statics
class User extends Model<User, UserRow> {
static bool creating() => true; // return false to cancel
static void created() { print('saved'); }
// also: updating/updated, deleting/deleted
}
// Registry (Dart-idiomatic, has access to the model instance)
class User extends Model<User, UserRow> {
@override
ObserverSet get $observers => ObserverSet(
creating: (u) => (u.toMap()['email'] as String).contains('@'),
created: (u) => print('saved ${u.toMap()["id"]}'),
updated: (u) => print('updated ${u.toMap()["id"]}'),
);
}
Hooks fire in this order on instance save() / update() /
delete():
creating → INSERT → created
updating → UPDATE → updated
deleting → DELETE → deleted
ModelQuery.create() bypasses the cancel path (creating) because
it inserts via Drift directly, then fires created post-insert. If
you need cancellation, use Model.save() from an instance.
Returning false from creating / updating / deleting aborts
the operation and throws OperationCancelledException.
Auto timestamps
class User extends Model<User, UserRow> with WithTimestamps { … }
The mixin:
- Sets
created_at = DateTime.now()on the firstsave()(if null). - Bumps
updated_at = DateTime.now()on everysave()andupdate().
It detects column presence at runtime — if the table is missing
created_at / updated_at, the mixin no-ops silently.
Schema: declaring tables
The Schema facade emits SQL via a Blueprint DSL. SQLite storage
types are inferred from the column helpers; modifiers are chainable.
await Schema.create('users', (t) {
t.id();
t.string('email').unique_();
t.string('name');
t.boolean('active').default_(true);
t.dateTime('deleted_at').nullable_();
t.timestamps();
});
await Schema.create('posts', (t) {
t.id();
t.integer('user_id');
t.string('title');
t.text('body').default_('');
t.foreign('user_id', references: 'users.id', onDelete: 'CASCADE');
});
Column helpers:
| Helper | Storage | Notes |
|---|---|---|
t.id() |
INTEGER PRIMARY KEY AUTOINCREMENT |
Conventional PK. |
t.string(name) |
TEXT |
Short strings, enums. |
t.text(name) |
TEXT |
Long-form text. |
t.integer(name) |
INTEGER |
|
t.real(name) |
REAL |
Double-precision float. |
t.boolean(name) |
INTEGER |
0/1 (SQLite has no native BOOL). |
t.dateTime(name) |
TEXT |
ISO-8601. |
t.blob(name) |
BLOB |
|
t.timestamps() |
nullable created_at + updated_at |
Filled by WithTimestamps. |
Modifiers (chainable, mutate the column in place):
t.string('email').unique_();
t.string('nickname').nullable_();
t.string('slug').default_('untitled');
t.boolean('verified').default_(false);
Constraints:
t.compositePrimary(['user_id', 'role_id']); // for pivot tables
t.foreign('user_id', references: 'users.id', onDelete: 'CASCADE');
t.index(['user_id', 'created_at']);
t.unique(['user_id', 'slug']);
foreign() accepts either Laravel-style 'users.id' or SQLite-style
'users(id)'; both are normalized to users(id) in the rendered SQL.
Inspect the database:
if (await Schema.hasTable('users')) { … }
final cols = await Schema.getColumns('users'); // ['id', 'email', ...]
final hasEmail = await Schema.hasColumn('users', 'email');
Drop / wipe:
await Schema.drop('posts'); // DROP TABLE IF EXISTS
await Schema.dropUnlessExists('posts'); // bare DROP TABLE
await Schema.dropAll(); // every non-sqlite_* table
Migrations: evolving the schema
Each migration is a class with up() and down(). Register with a
Migrator, then call migrate() at boot:
class AddUserPhone extends Migration {
const AddUserPhone();
@override
Future<void> up() async {
await Schema.table('users', (t) {
t.addColumn('string', 'phone').default_('');
});
}
@override
Future<void> down() async {
await Schema.table('users', (t) {
t.dropColumn('phone');
});
}
}
List<Migration> allMigrations() => const <Migration>[
CreateUsers(),
AddUserPhone(),
];
Future<void> migrate() async =>
(Migrator()..register(allMigrations())).migrate();
The Migrator keeps an _migrations(name, batch) ledger table;
each migration is run inside Eloquent.transaction(...) so a
partial failure rolls back the schema change. Re-running
migrate() only applies new migrations.
final m = Migrator()..register(allMigrations());
await m.migrate(); // apply pending
await m.rollback(steps: 1); // run down() on the last batch
await m.migrate(); // re-apply
await m.fresh(); // drop every table + re-migrate
Drop / rename columns follow the same pattern. ALTER TABLE DROP COLUMN requires SQLite 3.35+ (March 2021).
API reference
Eloquent facade
| Member | Description |
|---|---|
Eloquent.init(db) |
Wire the user's @DriftDatabase. Call once at app start. |
Eloquent.db |
The GeneratedDatabase. Use for joins, customSelect with readsFrom, etc. |
Eloquent.transaction(action) |
Run action inside a Drift transaction. |
Eloquent.raw(sql, [vars]) |
Execute a SQL statement. Returns rowid for inserts. |
Eloquent.rawSelect(sql, [vars]) |
Execute a SQL SELECT; returns List<Map<String, Object?>>. |
Eloquent.dispose() |
Drop the package's reference to the database. Does not close the database — the caller owns its lifecycle. |
Model<T, D>
Abstract base.
| Member | Description |
|---|---|
$data |
The current row data. Mutable. |
$exists |
true after the row has been INSERTed at least once. |
$table |
Abstract: the Drift TableInfo for this model's table. |
$wrap(d) |
Abstract: wrap a row into this model. |
wrap(d) |
Internal helper: flip $exists = true on an existing instance. |
$primaryKey |
Primary-key column name. Defaults to 'id'. |
toMap() |
Abstract: serialize $data to Map<String, dynamic>. |
toMapWithPending() |
toMap() merged with pending setAttribute writes. |
$casts |
Per-column cast registry. |
$relations |
Registry of named relations, used by with_(...). |
$observers |
Lifecycle hooks (see Observers). |
$original / $dirty / $changes |
Unmodifiable views of the last snapshot, the dirty columns, and the columns that changed in the most recent lifecycle. |
getAttribute(key) / setAttribute(key, value) |
Read / write a column through the cast registry. |
getOriginal(key) |
The value of key at the last snapshot. |
isDirty([key]) / isClean([key]) |
true if key (or any column) has a pending write. |
wasChanged([key]) |
true if key (or any column) was written in the most recent lifecycle. |
save() |
Insert (if no PK) or update (if PK present). Fires observers. |
update(map) |
Merge map into toMap() and update the row. |
delete() |
Delete the row from the database. |
refresh() |
Re-fetch the row from the database. |
saveQuietly() / deleteQuietly() |
Like save / delete but no observers fire. |
getLoaded(name) / isLoaded(name) |
Inspect an eagerly-loaded relation or aggregate. |
Model.withoutEvents(action) |
Run action with every observer globally suppressed. |
ModelQuery<T, D>
One per model — declared as static final _q = ModelQuery(...) inside
the model. The forwarding statics template wraps these.
| Method | Description |
|---|---|
all() / find(id) / findOrFail(id) / first() |
Standard lookups. |
count() / exists() |
Aggregates. |
watch() |
Stream<List<T>> that re-emits on any write to the table. |
create(map) / createMany(rows) |
Insert one or many. Casts applied. Fires created after the row exists. |
firstOrCreate(attrs, [creational]) / firstOrNew(attrs, [creational]) / updateOrCreate(attrs, values) / upsert(rows, [uniqueBy]) |
Idempotent creation helpers. |
update(map, whereColumn: ..., whereValue: ...) |
Mass update. |
delete(whereColumn: ..., whereValue: ...) |
Mass delete. |
where(c, [v, op]) |
Start a chain. Equivalent to query().where(...). |
query() |
Start an empty chain. |
QueryBuilder<T, D> implements Selectable<T>
Chainable query builder. See Chainable queries for the full method table.
Schema
Static facade — see Schema: declaring tables.
| Method | Description |
|---|---|
Schema.create(table, builder) |
CREATE TABLE. |
Schema.drop(table) |
DROP TABLE IF EXISTS. |
Schema.dropUnlessExists(table) |
Bare DROP TABLE. |
Schema.table(table, builder) |
ALTER TABLE (uses Blueprint alter methods). |
Schema.hasTable(table) |
bool. |
Schema.hasColumn(table, column) |
bool. |
Schema.getColumns(table) |
List<String>. |
Schema.dropAll() |
Drop every non-sqlite_* table. |
Blueprint
Used inside Schema.create / Schema.table closures. See
Schema: declaring tables.
Migration / Migrator
| Method | Description |
|---|---|
Migration.up() |
Apply the schema change. |
Migration.down() |
Inverse of up(). |
Migration.name |
Identifier used by the ledger. Defaults to runtimeType.toString(). |
Migrator.register(list) |
Append migrations to the run queue. |
Migrator.migrate() |
Apply any not yet recorded. |
Migrator.rollback({steps: 1}) |
Run down() on the most recent batch. |
Migrator.fresh() |
Drop every table, clear the ledger, re-migrate. |
Exceptions
EloquentException is the sealed base. Subclasses:
ColumnNotFoundException, TableNotFoundException,
ModelNotFoundException, UnsupportedOperatorException,
RelationNotFoundException, OperationCancelledException,
InvalidArgumentException, MultipleRecordsFoundException,
ModelNotSoftDeletableException.
Limitations
distinct()— Drift'sSimpleSelectStatement.distinctisfinal, so we can't toggle it post-construction. ForSELECT DISTINCT …useEloquent.db.customSelect(sql, readsFrom: {...}).watch()directly.ModelQuery.create()does not firecreating— it inserts directly, then firescreatedpost-insert. UseModel.save()from an instance if you need the cancel path.- Forwarding statics — ~10 lines of boilerplate per model. A codegen builder is on the roadmap.
ALTER TABLE DROP COLUMNrequires SQLite 3.35+ (March 2021) or newer. Older runtimes fail at execute time, not at parse time.- SQLite only — Drift itself targets SQLite (native),
PostgreSQL (server) and Cloud Spanner. The wrapper is exercised
against
NativeDatabase.memory()and should work on any Drift backend, but the Blueprint DSL emits SQLite-flavored DDL. watch()withwithCount/withSum/ etc. falls back to a one-shotget()— the reactive path through Drift'saddColumnsisn't fully re-implemented yet.
Roadmap
- Codegen of forwarding statics (
eloquent_flutter_codegenbuilder) - Local scopes
selectRaw,whereRaw(String)for raw SQL fragments with bindings- Full sync engine / offline-first
- PostgreSQL / Cloud Spanner parity tests
Contributing
Issues and pull requests are welcome. For anything beyond a typo:
- Open an issue describing the change you want to make and why.
- Wait for a maintainer to acknowledge before sending large PRs.
- Make sure
dart testanddart analyzeboth pass.
The benchmark suite is the source of truth for performance regressions. Run it before and after a change that touches the read path:
dart test
dart analyze
dart run benchmark/eloquent_vs_drift.dart
Maintainer
Ravdeep Singh
Lead Developer, Ubxty
Built and maintained as part of the Ubxty open-source stack. Also see ubxcert — a dependency-free ACME v2 / Let's Encrypt CLI written in PHP, the same author's companion project.
License
MIT © Ubxty
Libraries
- eloquent_flutter
- Laravel Eloquent-style ORM for Flutter and Dart, built on top of Drift.