Skip to content

Database

SQLite is the default connection in a new Maat application. Configure the file, then query it through the DB facade:

DB_CONNECTION=sqlite
DB_DATABASE=database/database.sqlite
final posts = await DB.table('posts')
.where('published', true)
.orderByDesc('created_at')
.get();

Maat talks to your database through seshat_maat, which wires the seshat package — query builder, models, schema builder, migrator — into the framework. seshat does not depend on Maat, so the same query builder works in a plain Dart script; seshat_maat is what gives you config/database.dart, the migrate commands, seeders, and the unique and exists validation rules.

A new application already depends on it, and bootstrap/app.dart already registers the provider:

.withProviders([
AppServiceProvider.new,
DatabaseServiceProvider.new,
(app) => RouteServiceProvider(app, api: apiRoutes, web: webRoutes),
])

Connections

Database configuration lives in config/database.dart. It names a default connection and a map of connections, exactly like Laravel’s config/database.php:

final Map<String, dynamic> database = {
'default': env('DB_CONNECTION', 'sqlite'),
'connections': {
'sqlite': {
'driver': 'sqlite',
'database': env('DB_DATABASE', 'database/database.sqlite'),
},
'pgsql': {
'driver': 'pgsql',
'host': env('DB_HOST', '127.0.0.1'),
'port': envInt('DB_PORT', 5432),
'database': env('DB_DATABASE', 'maat'),
'username': env('DB_USERNAME', 'postgres'),
'password': env('DB_PASSWORD', ''),
'ssl': env('DB_SSL', 'disable'),
'pool': {'max': envInt('DB_POOL_MAX', 5)},
},
'mysql': { /* ... */ },
},
};

DatabaseServiceProvider reads that map during boot, opens the connection named by default, and installs it as the process-wide default so DB.table(), models and the migrate commands all find it. You never call DB.use() in an application.

Pick a connection in .env:

Key Used by Default
DB_CONNECTION which block under connections to open sqlite
DB_DATABASE SQLite file path, or the database name on a server database/database.sqlite
DB_HOST pgsql, mysql 127.0.0.1
DB_PORT pgsql, mysql 5432 / 3306
DB_USERNAME pgsql, mysql postgres / root
DB_PASSWORD pgsql, mysql empty
DB_SSL PostgreSQL TLS (disable turns it off) disable
DB_POOL_MAX maximum connections per process/isolate 5 for PostgreSQL, 10 for MySQL

An application with no database key in its config boots normally with no connection at all. An API that talks only to other services is not forced to have a database.

config/database.dart ships a mysql block that does not work out of the box. DatabaseServiceProvider registers adapters for sqlite and pgsql only. Setting DB_CONNECTION=mysql throws ConnectionException: No adapter registered for driver "mysql" until the application adds the seshat_mysql package and calls registerMysqlDriver(). See Driver Adapters.

PostgreSQL uses package:postgres’s pool. Ordinary statements may run concurrently; a transaction reserves one session until it commits or rolls back. DB_POOL_MAX applies per process or isolate, so the database can see up to workers × DB_POOL_MAX connections from one application instance.

Outside an application

seshat on its own has no config file. Open a connection and make it the default:

import 'package:seshat/seshat.dart';
import 'package:seshat/sqlite.dart';
DB.use(SqliteConnection.open('storage/database.sqlite'));

SqliteConnection.inMemory() is the right choice in tests.

Running Raw Queries

final rows = await DB.select('select * from users where id = ?', [1]);
final affected = await DB.statement('update users set active = ? where id = ?', [false, 7]);
await DB.statement('vacuum');

DB.select returns List<Row>, where Row is Map<String, Object?>. DB.statement returns the number of affected rows. Both take positional ? bindings — never interpolate a value into the SQL string yourself.

The Query Builder

DB.table() returns a query builder whose rows are plain maps:

final rows = await DB.table('users').where('active', true).get();
final row = await DB.table('users').where('id', 1).first(); // Row or null
final n = await DB.table('users').count();
await DB.table('users').insert({'name': 'Ann', 'email': 'ann@example.com'});
await DB.table('users').where('id', 1).update({'active': false});
await DB.table('users').where('id', 1).delete();

The methods are the same ones Seshat models use, so anything you learn here applies there too:

  • Selecting: select, addSelect, selectRaw, distinct
  • Joins: join, leftJoin, rightJoin, crossJoin
  • Where: where(column, value), where(column, operator, value), where((q) => ...) for groups, orWhere, whereNot, whereNull, whereNotNull, whereIn, whereNotIn, whereBetween, whereLike, whereColumn, whereExists, whereRaw
  • Grouping and ordering: groupBy, having, havingRaw, orderBy(column, descending: true), orderByDesc, latest, oldest
  • Limits: limit/take, offset/skip, forPage
  • Reading: get, first, firstOrFail, find, count, exists, doesntExist, sum, avg, min, max, pluck, value, paginate, chunk, lazy
  • Writing: insert, insertGetId, update, increment, decrement, delete, truncate
  • Debugging: toSql(), compile()

See Seshat for models and relationships, and seshat’s own API for the details of each method.

Bindings and identifiers

Every value reaches the driver as a bound parameter; the builder never interpolates one. Table and column names are validated against [A-Za-z_][A-Za-z0-9_]* (dot-qualified allowed) and anything else throws InvalidIdentifierException. Operators are whitelisted.

RawSql is the single escape hatch:

DB.table('orders')
.select([RawSql.expression('date(created_at) as day')])
.whereRaw('json_extract(meta, ?) = ?', [r'$.plan', 'pro'])
.groupBy(['day'])
.get();

Raw fragments are trusted code. Never build one out of request input — put the values in the bindings list instead.

Transactions

The everyday form takes a callback. It commits when the callback returns, rolls back and rethrows when it throws, and returns whatever the callback returned:

await DB.transaction((tx) async {
await DB.table('accounts').where('id', 1).decrement('balance', 100);
await DB.table('accounts').where('id', 2).increment('balance', 100);
});

Notice that the two statements above do not mention tx and are still inside the transaction. DB.transaction pins the transaction’s connection into a Zone, and DB.table() resolves through DB.effectiveConnection, which prefers the pinned connection over the default one.

That is deliberate, and it is the piece that differs most from Laravel. PHP gives every request its own process and its own connection, so Laravel’s DB facade can hold a single “current connection” and everything a request touches lands on it. Dart has one process serving every request concurrently, so a static current-connection field would leak one request’s transaction into another’s queries. A Zone is Dart’s equivalent of that ambient state: it is scoped to the callback and to everything it awaits, and nothing outside sees it. So a service called three layers down joins the surrounding transaction without anyone threading a handle through three constructors:

Future<void> placeOrder(Map<String, dynamic> data) => DB.transaction((tx) async {
final id = await DB.table('orders').insertGetId(data);
await _chargeCard(id); // any DB.table() inside is in the transaction
});

The explicit handle still exists, and you need it in two cases: when a call must run on the transaction from code the zone does not cover (a callback handed to a driver, an isolate), and with Seshat models, whose using() takes a connection:

await DB.transaction((tx) async {
final user = await User.using(tx).create(data);
await Profile.query().using(tx).create({'user_id': user.id});
await DB.table('audit', connection: tx).insert({'user_id': user.id});
});

tx is itself a Connection. A nested DB.transaction(...) becomes a savepoint, so an inner failure you catch does not lose the outer work.

A Future you start inside the callback but do not await escapes the transaction’s lifetime, not its zone: it can still be running when the transaction commits. Always await your work inside the callback.

Migrations

Migrations are version control for your schema. Migrations covers the schema builder and the column types; this section covers the workflow.

Creating migrations

Terminal window
maat make:migration create_posts_table --create=posts
maat make:migration add_slug_to_posts --table=posts
maat make:migration backfill_slugs

--create scaffolds an up that creates the table and a down that drops it. --table scaffolds an up and a down that both open the table for alteration with empty bodies. With neither flag you get empty up and down methods.

The command writes two files. The migration itself lands in database/migrations/:

database/migrations/m2026_09_02_115436_create_posts_table.dart
import 'package:seshat_maat/seshat_maat.dart';
class CreatePostsTable extends Migration {
@override
String get name => '2026_09_02_115436_create_posts_table';
@override
Future<void> up(SchemaBuilder schema) => schema.create('posts', (t) {
t.id();
});
@override
Future<void> down(SchemaBuilder schema) => schema.dropIfExists('posts');
}

The m prefix on the file name is not decoration. Dart’s file_names lint requires a file name shaped like an identifier, and a Dart identifier cannot start with a digit — so Laravel’s bare 2026_09_02_115436_create_posts_table.php has no legal Dart equivalent. The name getter, which is what the migrations table records and what makes a migration unique, keeps the unprefixed timestamp.

The second file is the registry, database/migrations.dart, which the command rewrites to add both the import and the list entry:

import 'package:seshat_maat/seshat_maat.dart';
import 'migrations/m2026_09_02_115436_create_posts_table.dart';
/// Every migration, in the order they run.
final migrations = <Migration>[
CreatePostsTable(),
];

There is no directory scan: Dart has no reflection, so a migration exists only because this list names it. Run order is the list’s order, not the file name’s timestamp — reorder the list if you need to. If you write a migration by hand, add it here yourself, or it will never run.

make:migration refuses to overwrite an existing file without --force, and refuses outright to register a class name that is already in the registry — two classes with the same name would be an ambiguous_import the moment the project is analyzed.

Running migrations

Command Description
migrate Run every pending migration as one new batch.
migrate:rollback Roll back the last batch. --step=2 rolls back the last two.
migrate:status Table of every registered migration: whether it ran, and in which batch.
migrate:fresh Drop every table, then re-run every migration. --seed seeds afterwards.
Terminal window
maat migrate
maat migrate:status
maat migrate:rollback --step=2
maat migrate:fresh --seed

Each migration runs inside its own transaction, and its row in the migrations table is written in that same transaction. On SQLite and PostgreSQL, which have transactional DDL, a failing up() therefore leaves nothing behind at all — no half-created table, no repository row — and you can fix the migration and re-run.

MySQL commits implicitly on DDL, and this is a real hazard. create table, alter table and friends end the surrounding transaction on the spot. A migration whose up() runs three statements and fails on the third leaves the first two applied and writes no repository row, so the next migrate starts the same migration again on a database that is already half-changed. Keep MySQL migrations to a single DDL statement, or write up() so it is safe to run twice, and check the schema by hand after any failure. Laravel has the same limitation for the same reason.

A --table migration’s down() is a no-op. The generated body is an empty schema.table(...) block, so migrate:rollback reports Rolled back: and removes the repository row while changing nothing in the schema. Laravel’s --table stub behaves the same way. Write the reversing statements yourself, or accept that the rollback is bookkeeping only.

migrate, migrate:fresh and db:seed refuse to run when APP_ENV=production unless you pass --force. migrate:rollback carries no such guard — it is not interactive and will roll back a production batch without asking.

Wiring in the console kernel

bin/maat.dart composes the commands:

import '../database/migrations.dart';
import '../database/seeders/database_seeder.dart';
Future<void> main(List<String> args) async {
final application = await createApp();
exit(await Sesh(
application,
commands: commands(migrations: migrations, seeders: seeders),
).run(args));
}
lib/app/console/kernel.dart
List<Command> commands({
required List<Migration> migrations,
required List<Seeder> seeders,
}) => [...databaseCommands(migrations: migrations, seeders: seeders)];

The kernel takes the two registries as parameters instead of importing them because it cannot import them: a file under lib/ may not reach outside the package’s lib/ root with a relative import, and database/migrations.dart lives beside lib/, not inside it. bin/maat.dart is outside lib/, so it can read both and pass them in. That is the whole reason for the parameters — keep the composition there and the kernel stays a list of commands.

Seeding

A seeder is a class with one method:

Terminal window
maat make:seeder PostSeeder
database/seeders/post_seeder.dart
import 'package:seshat_maat/seshat_maat.dart';
class PostSeeder extends Seeder {
@override
Future<void> run() async {
await DB.table('posts').insert({'title': 'Hello', 'body': 'World'});
}
}

Register it in database/seeders/database_seeder.dart — this list is hand-maintained, make:seeder does not touch it:

final seeders = <Seeder>[DatabaseSeeder(), PostSeeder()];
class DatabaseSeeder extends Seeder {
@override
Future<void> run() async {}
}
Terminal window
maat db:seed # runs DatabaseSeeder
maat db:seed --class=PostSeeder # runs one seeder by class name
maat migrate:fresh --seed # runs every registered seeder, in order

db:seed matches --class against the runtime class name of each registered seeder and fails with the list of registered names if there is no match. There is no call() helper for chaining seeders from inside another: to run several, either register them all and use migrate:fresh --seed, or call the other seeder’s run() directly.

Multiple Connections

The provider opens exactly one connection: the one named by default. Extra connections are opened by hand and passed where they are needed:

final reporting = await PostgresConnection.open(
host: env('REPORTING_HOST', '127.0.0.1'),
database: env('REPORTING_DB', 'reporting'),
username: env('REPORTING_USER'),
password: env('REPORTING_PASSWORD'),
);
await DB.table('events', connection: reporting).get();
await User.using(reporting).get();
await Schema.on(reporting).hasTable('events');

A good place for that is a service provider’s boot, binding the connection into the container so the rest of the application can resolve it. There is no DB.connection('name') resolver and no connection manager; naming a second connection in config/database.dart does not open it.

Driver Adapters

Driver Package Notes
sqlite sqlite3 (FFI) Needs libsqlite3 on the host. One connection per isolate. :memory: for tests.
pgsql postgres (pure Dart) Transactional DDL; nested transactions use savepoints. bigserial, varchar(n), numeric(p,s), jsonb, native booleans.
mysql mysql_client, via the separate seshat_mysql package Not registered by default; see below.

MySQL

MySQL lives in its own package. Add it to pubspec.yaml, then register the driver before creating the application:

import 'package:seshat_mysql/seshat_mysql.dart';
Future<void> main(List<String> args) async {
registerMysqlDriver();
final application = await createApp();
// ...
}

registerMysqlDriver() teaches DatabaseServiceProvider the mysql driver and registers the MySQL schema grammar, so migrations work too. Both happen together on purpose: a connection you could open but not migrate would be a trap.

mysql_client was last published in 2022 and is unmaintained. That is exactly why the MySQL adapter is a separate package rather than part of seshat_maat — the framework does not take an abandoned dependency on everyone’s behalf, and you opt in knowingly. PostgreSQL and SQLite are on actively maintained drivers; prefer them for new work.

Two more things to know before pointing an application at MySQL:

  • The adapter defaults to TLS on (secure: true). Turn it off only for a local server that cannot negotiate TLS.
  • registerMysqlDriver reads the connection’s pool value as an int, while the shipped config/database.dart writes 'pool': {'max': ...} — a map. Opening a MySQL connection with the shipped config as written is therefore expected to throw a cast error. Set 'pool' to a plain integer in config/database.dart until this is reconciled.

Validation Rules

Registering DatabaseServiceProvider also registers the unique and exists validation rules, which query the default connection. See Validation for the syntax and its limits.

Query Events

DB.connection.listen((e) => Log.debug('${e.duration.inMilliseconds}ms ${e.sql} ${e.bindings}'));

In tests, enableQueryLog() followed by queryLog lets you assert exactly how many statements a code path issued — which is how the eager-loading tests prove there is no N+1.

Verified Against

SQLite is exercised end to end by the test suite: the provider, the migrate commands, seeding, the validation rules and the query builder all run against a real in-memory SQLite database on every test run.

PostgreSQL and MySQL support is implemented but has not been exercised against a live server. No database server was available while this layer was written, so the PostgreSQL and MySQL paths are covered only by grammar-level tests that assert the SQL text. Treat the following as unverified until you have run them yourself: the MySQL connection suite, auto_increment column attribute ordering, MySQL 8.0’s rename column, the MySQL TLS default and its config forwarding, and the string-parameter behaviour of unique/exists on PostgreSQL described in Validation.