Database
SQLite is the default connection in a new Maat application. Configure the file,
then query it through the DB facade:
DB_CONNECTION=sqliteDB_DATABASE=database/database.sqlitefinal 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.dartships amysqlblock that does not work out of the box.DatabaseServiceProviderregisters adapters forsqliteandpgsqlonly. SettingDB_CONNECTION=mysqlthrowsConnectionException: No adapter registered for driver "mysql"until the application adds theseshat_mysqlpackage and callsregisterMysqlDriver(). 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 nullfinal 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
Futureyou start inside the callback but do notawaitescapes 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
maat make:migration create_posts_table --create=postsmaat make:migration add_slug_to_posts --table=postsmaat 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/:
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
mprefix on the file name is not decoration. Dart’sfile_nameslint requires a file name shaped like an identifier, and a Dart identifier cannot start with a digit — so Laravel’s bare2026_09_02_115436_create_posts_table.phphas no legal Dart equivalent. Thenamegetter, 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. |
maat migratemaat migrate:statusmaat migrate:rollback --step=2maat migrate:fresh --seedEach 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 tableand friends end the surrounding transaction on the spot. A migration whoseup()runs three statements and fails on the third leaves the first two applied and writes no repository row, so the nextmigratestarts the same migration again on a database that is already half-changed. Keep MySQL migrations to a single DDL statement, or writeup()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
--tablemigration’sdown()is a no-op. The generated body is an emptyschema.table(...)block, somigrate:rollbackreportsRolled back:and removes the repository row while changing nothing in the schema. Laravel’s--tablestub 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));}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:
maat make:seeder PostSeederimport '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 {}}maat db:seed # runs DatabaseSeedermaat db:seed --class=PostSeeder # runs one seeder by class namemaat migrate:fresh --seed # runs every registered seeder, in orderdb: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_clientwas last published in 2022 and is unmaintained. That is exactly why the MySQL adapter is a separate package rather than part ofseshat_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. registerMysqlDriverreads the connection’spoolvalue as anint, while the shippedconfig/database.dartwrites'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 inconfig/database.dartuntil 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.