Skip to content

Cheat sheet

One page to keep open while you work.

Each section links to its full page.

Project layout

Path What is in it
bin/server.dart starts the app: Dox().initialize(), migrations, Dox().startServer()
bin/migrate.dart runs migrations for dox migration:*
bin/seed.dart runs seeders for dox db:seed
bin/queue.dart the queue worker for dox queue:*
bin/schedule.dart the scheduler for dox schedule:*
bin/openapi.dart you add it: writes the OpenAPI files for dox openapi:generate
lib/config/ app.dart (AppConfig), env.dart, database.dart, cors.dart, cache.dart, storage.dart, logger.dart, queue.dart, schedule.dart. You add others, like openapi.dart.
lib/routes/ api.dart, web.dart: one Router class each
lib/app/http/controllers/ <name>.controller.dart
lib/app/dtos/<name>/ <name>.dto.dart
lib/app/http/middleware/ <name>.middleware.dart
lib/app/serializers/ <name>.serializer.dart
lib/app/models/<name>/ <name>.model.dart
lib/app/jobs/ <name>.job.dart
lib/providers/ DoxProvider classes: ORM, storage, queue, schedule. You add others, like auth.
lib/generated/ container.g.dart, openapi.g.dart, jobs.g.dart and dtos.g.dart (an empty list when the app has no DTO). Made by build_runner. Do not edit.
db/migration/ migrations and migrations.dart (the registry)
db/seeders/, db/factories/ seeders (seeders.dart is the registry) and model factories

See CLI and App config.

CLI

dox create my_app                  # newest template
dox serve                          # dev server + build_runner watch
dox build                          # AOT bundle in build/bundle
dox build_runner:build             # make .g.dart files
dox key:generate                   # APP_KEY in .env
dox env:generate                   # lib/config/env.dart from .env.example
dox env:check                      # check the env, exit 1 when bad
dox make:controller Blog [-r]
dox make:middleware Auth
dox make:dto CreateBlog
dox make:serializer Blog
dox make:model Blog -m             # model + migration
dox make:migration create_blogs_table [--sql]
dox make:job SendWelcomeEmail
dox migrate                        # migration:run
dox migrate --seed                 # then run the seeders
dox migration:rollback --step=1
dox migration:status
dox db:seed [--class=UserSeeder]
dox queue:work                     # run queued jobs
dox schedule:work                  # run scheduled tasks
dox openapi:generate [dir]

See CLI.

Routes

class ApiRouter extends Router {
  @override
  String get prefix => 'api';

  @override
  List<dynamic> get middleware => <dynamic>[];

  @override
  void register() {
    Route.get('/ping', (DoxRequest req) => 'pong');
  }
}
Code Does
Route.get('/blogs', (C c) => c.index) controller C from the container. Also post, put, patch, delete, options
Route.get('/blogs', listBlogs) a plain function
Route.post('/blogs', c.store, body: schema) body: is for the docs only. It checks nothing.
Route.get('/blogs/{id}', c.show) path param. show(int id)
Route.get('/posts/{page?}', c.list) optional param. list(int? page)
Route.get('/docs/{path*}', c.page) rest of the path, with /
Route.get('/me', <dynamic>[AuthMiddleware(), c.me]) middleware first, controller last
Route.match(<String>['GET', 'POST'], '/contact', handler) one handler for a list of methods
Route.any('/webhook', handler) one handler for GET, POST, PUT, PATCH and DELETE
Route.redirect('/home', '/') / Route.permanentRedirect('/old', '/new') send a path to another URL. 302 by default, 301 for permanentRedirect.
Route.get('/users/{id}', c.show).where('id', '[0-9]+') the route matches only when the param fits the regex
Route.fallback((DoxRequest req) { ... }) answers a GET or HEAD request that matches no route and no static file. Use it for a 404 page or an SPA.
globalMiddleware: <dynamic>[const MethodOverride()] lets an HTML form reach PUT, PATCH and DELETE routes
Route.group('/blogs', () { ... }) prefix. Dox adds the / between parts.
Route.middleware(<dynamic>[M()], () { ... }) middleware for a group
Route.domain('api.app.com', () { ... }) match by Host
Route.resource('blogs', BlogController) index, create, store, show, edit, update, destroy
Route.health('/health') 200 {"status": "ok"}, or 503 when a check fails. See Health check.
Route.staticFiles('/', 'public') serve a folder (CSS, JS, images) when no route matches. maxAge: sets Cache-Control
Storage.route('files') serve a storage disk. Only signed URLs work, unless signed: false (dox_storage)
Route.enabledCors(() { ... }) / Route.disabledCors(...) CORS for some routes
Route.websocket('echo', (WebSocket s, DoxRequest req) { ... }) a raw WebSocket. For rooms and acks, use Socket.IO.

Controller parameters, by type: DoxRequest, one @Dto class, String/int/double/num/bool for path params (by position), a @DoxModel (loaded by its key), an @Inject() class. Only positional parameters.

In the examples, c.index and the like are methods of a controller C from the container.

Dependency injection

@Inject()                       // one instance per isolate
class PostService {
  PostService(this.posts);      // from the container, by type
  final PostRepository posts;
}

@Inject(singleton: false)       // a new instance each time
class Clock {}

AppConfig(container: registerContainer); // from lib/generated/container.g.dart

Dox().ioc.get<PostService>();
Dox().ioc.override<PostRepository>(FakePostRepository()); // tests

Routers and DoxProvider classes are found without @Inject(). Run dart run build_runner build after a change. See Dependency injection.

Middleware order: globalMiddleware, router middleware, group, route.

See Routing, Controller, Middleware.

Middleware

class ApiKeyMiddleware implements IDoxMiddleware {
  @override
  dynamic handle(IDoxRequest req) {
    String apiKey = Env.get<String>('API_KEY');
    // Hash.equals: constant time, so the time does not leak the key
    if (apiKey.isEmpty || !Hash.equals(req.header('X-Api-Key') ?? '', apiKey)) {
      return response('no').statusCode(401); // stop here
    }
    return req; // go on
  }
}
Middleware Does
const RequestId() X-Request-Id header, req.requestId
const ETag() ETag header, 304 when If-None-Match matches
RequestTimeout(Duration(seconds: 30)) or Route.get(...).timeout(...) 503 when the route is too slow
const ValidateSignature() 403 unless the url is from Route.signedUrl(...)
implements IDoxTerminableMiddleware terminate(req, res) runs after the answer is sent

See Middleware and Signed URLs.

Request

Code Gives
await req.validate<CreateBlog>() checked DTO, or 422
req.input('name') one value, query or body (body wins)
req.all() / req.only(<String>['a', 'b']) many values
req.input('user.name', 'guest') dot key, with a default
req.except(<String>['password']) all values but these
req.has('name') / req.filled('name') key is there and not empty (only filled reads dot keys)
req.exists('name') / req.missing('name') key is there (any value) / not there
req.boolean('remember') / req.integer('page', 1) value as bool / int
req.file('avatar') / req.hasFile('avatar') uploaded file
req.bearerToken() token of Authorization: Bearer
req.path() / req.fullUrl() path / full URL with query
req.pathIs('admin/*') / req.routeIs('admin.*') path or route name matches
req.body / req.query / req.param['id'] one source only
req.header('X-Token') / req.headers headers
req.cookie('token') decrypted cookie
req.ip() / req.scheme() / req.isSecure() client info. Needs trustedProxies behind a proxy.
req.userAgent() / req.host() / req.origin() / req.locale more headers
req.isJson() / req.isFormData() content type
req.auth?.user<User>() logged in user (after AuthMiddleware)
req.auth?.id() / req.auth == null user id, or true for a guest
req.method / req.uri method and URI

Files:

RequestFile file = req.input('avatar');
file.filename; file.extension; file.length;   // bytes
String path = await file.store('avatars');    // save before you return (dox_storage)

See Request and Request and response.

Response

Return Client gets
String text
Map, List, model, Pagination, serializer JSON
File streamed file, Range supported
Storage().stream(path) / Storage().download(path) file / download
Stream<List<int>> streamed bytes
throw NotFoundHttpException() 404 Not Found
abort(403), abort(409, 'Taken') any status
response(...) full control
return response(<String, dynamic>{'id': 1})
    .statusCode(201)
    .header('X-Request-Id', 'abc')
    .contentType(ContentType.json)
    .cache(Duration(minutes: 5))
    .cookie(DoxCookie('theme', 'dark', httpOnly: true, secure: true));

response('ok').cookie(DoxCookie('token', ''), setExpire: true); // delete
Exception Status
BadRequestException() 400
UnAuthorizedException() (dox_auth) 401
ForbiddenHttpException() 403
NotFoundHttpException() 404
MethodNotAllowedHttpException(['GET']) 405
PayloadTooLargeException() 413
UnsupportedMediaTypeException() 415
ValidationException() 422
InternalErrorException() 500
HttpStatusException(code) any

See Response and HTTP exceptions.

DTO validation

@Dto()
class CreateBlog {
  @Length(min: 3, max: 200)
  @Trim()
  final String title;

  final String? body;            // nullable = optional
  final Status status;           // enum = only its names

  const CreateBlog({required this.title, this.body, this.status = Status.draft});

  static const DtoSchema<CreateBlog> schema = $CreateBlogDto;
}

Then dox build_runner:build, and dtos: dtos in AppConfig.

Kind Annotations
Text @Length(min:, max:), @MinLength(3), @MaxLength(50), @Email(), @Url(), @Uuid(), @Ip(), @Regex(r'..'), @In([..]), @NotIn([..]), @StartsWith('a'), @EndsWith('b'), @Alpha(), @AlphaNumeric(), @AlphaDash()
More text @Json(), @Digits(6), @DigitsBetween(4, 6), @DateFormatted('Y-m-d'), @Password(min: 8, mixedCase: true, numbers: true, symbols: true)
Other fields @Confirmed(), @Same('email'), @RequiredIf('type', 'company'), @RequiredWith(['phone'])
Change text @Trim(), @Lowercase(), @Uppercase(), @AllowEmpty()
Numbers @Min(1), @Max(9), @Range(1, 9), @Positive(), @GreaterThan(0), @LessThan(9), @In, @NotIn
Lists @MinItems(1), @MaxItems(5), @UniqueItems()
Dates @After('2000-01-01'), @Before('now')
Files @MaxSize('2MB'), @MinSize('1KB'), @Mimes(['pdf']), @MimeTypes(['image/png']), @Image(), @Dimensions(minWidth: 100)
Database (dox_orm) @Unique('users', column: 'email', ignoreParam: 'id'), @Exists('teams')
Keys @Name('emailAddress')
Docs @Describe('Shown in OpenAPI and MCP.') (only the JSON Schema description)
Class @Dto(source: Source.query, keyCase: KeyCase.field, strict: true, messages: {...})

Cross field check: add void validate(DtoErrors errors) to the DTO. PATCH "missing or null": Maybe<T> with const Maybe.missing().

See Validation and Validation rules.

Model

@DoxModel(table: 'blogs', softDelete: true)
class Blog extends BlogGenerator {
  @Column()
  String? title;

  @Column(name: 'user_id')
  int? userId;

  @BelongsTo(User)
  User? user;

  @HasMany(Comment)
  List<Comment> comments = <Comment>[];

  @override
  List<String> get hidden => <String>['secret'];
}
Code Does
await Blog().find(1) / find('slug', 'a') one row or null
await Blog().all() every row that matches where
await Blog().where('status', 'live').get() rows that match
await Blog().where('id', '>', 5).getFirst() first match or null
await Blog().findOrFail(1) / firstOrFail() the row, or throws ModelNotFoundException
await Blog().count() int
await Blog().where('slug', s).hasRows() / hasNoRows() bool
await Blog().pluck('title') / valueOf('title') one column of all rows / of the first row
await Blog().sum('views'), avg, min, max one value, null when no row
await Blog().paginate(currentPage: 1, perPage: 20) Pagination<Blog>, rows in data
await Blog().simplePaginate(currentPage: 1, perPage: 20) a page without the total count
await Blog().cursorPaginate(perPage: 20, cursor: c) a page after a cursor, nextCursor
await Blog().create(<String, dynamic>{'title': 'a'}) insert and return the model
await Blog().firstOrCreate({...}, {...}), firstOrNew, updateOrCreate find, or create
blog.title = 'b'; await blog.save() insert or update
await blog.delete() soft delete when softDelete: true. Returns the deleted rows as an int.
await blog.increment('views') / decrement change a number in one query
await Blog().forceCreate({...}) create with every key, ignore fillable
await Model.unguarded(() async { ... }) turn off the fillable check inside the callback
await blog.sync('tags', <int>[1, 2]) many to many: make the links exactly these ids
await blog.attach('tags', 3) / await blog.detach('tags', 3) add / remove links
await blog.forceDelete() / await blog.restore() real delete / undo soft delete
Blog().withTrash() include soft deleted rows
Blog().onlyTrashed() only soft deleted rows
await blog.touch() set updated_at to now, touchQuietly() with no events
blog.replicate() a new unsaved copy of the model
Blog().preload('comments').get() load a relation, one query
Blog().whereHas('comments').get() / whereDoesntHave / has('comments', '>=', 3) filter by a relation
await blog.$getRelation('user') load a relation on one model
blog.related<Comment>('comments')?.where(...).get() relation query
blog.toJson() / blog.toMap() map, hidden left out of toJson()
Blog().query() a new, empty query

Relations: @HasOne(T), @HasMany(T), @BelongsTo(T), @ManyToMany(T). Polymorphic: @MorphMany(T, 'name'), @MorphOne(T, 'name'), @MorphTo(). Options: foreignKey, localKey, onQuery, eager.

Events: override onCreating(), onCreated(), onSaving(), onUpdated(), onDeleting(), ... or Model.observe<Blog>(BlogObserver()). throw CancelModelEvent('why') in a "before" event stops it. saveQuietly() skips events.

See Model, Events, HasMany, Query by relations.

Query builder

List<Map<String, dynamic>> rows = await QueryBuilder.table('blogs')
    .select(<String>['id', 'title'])
    .where('status', 'live')
    .orWhere('views', '>', 100)
    .whereIn('id', <int>[1, 2, 3])
    .whereNull('deleted_at')
    .join('users', 'users.id', 'blogs.user_id')
    .orderBy('id', 'desc')
    .limit(10)
    .offset(20)
    .get();
Code Does
whereNotIn, whereNotNull, orWhereIn, orWhereNull more filters
where('a', null), where('a', '!=', null) IS NULL, IS NOT NULL
whereBetween, whereColumn, whereLike, whereExists (and Not, or forms) more filters
whereDate, whereTime, whereYear, whereMonth, whereDay parts of a date
whereJsonContains('tags', 'a'), whereJsonDoesntContain JSON values
whereIn('id', QueryBuilder.table('post').select('user_id')) sub query
whereGroup((QueryBuilder<Blog> q) { ... }), orWhereGroup, whereNot conditions in brackets
when(condition, (QueryBuilder<Blog> q) { ... }) add parts only when true
whereRaw('lower(name) = @n', <String, dynamic>{'n': 'a'}) raw SQL, bound values
leftJoin, rightJoin, joinRaw joins
joinSub(q, 'alias', 'alias.id', 'blog.id'), leftJoinSub, fromSub(q, 'alias') sub queries
union(q), unionAll(q) add the rows of another query
groupBy('age'), having(...), havingRaw(...) groups
distinct(), selectRaw('count(*) as total') distinct rows, raw select
latest(), oldest(), inRandomOrder() order
chunk(100, (rows) { ... }), chunkById(...) work on a big table
insert({...}), insertMultiple([...]) insert
upsert([...], ['email']) insert or update, returns an int
insertOrIgnore([...]) insert, skip duplicates, returns an int
where(...).update({...}) update, no events, no updated_at. Returns the changed rows as an int.
where(...).increment('views'), decrement change a number in one query
where(...).delete(), truncate() delete. delete() returns the deleted rows as an int.
lockForUpdate(), sharedLock() row lock, inside a transaction
QueryBuilder.query('select 1', substitutionValues: {...}) raw query
withConnection('mysql') another connection
toSql(), debug(true) see the SQL

See Query builder.

Migrations and schema

class CreateBlogsTable extends Migration {
  @override
  Future<void> up(Schema schema) async {
    await schema.create('blogs', (Table table) {
      table.id();
      table.foreignId('user_id').constrained('users').cascadeOnDelete();
      table.string('title');
      table.text('body').nullable();
      table.enumeration('status', <String>['draft', 'live']).defaultTo('draft');
      table.timestamps();
      table.softDeletes();
    });
  }

  @override
  Future<void> down(Schema schema) async {
    await schema.dropIfExists('blogs');
  }
}
Column types
id(), increments, bigIncrements, string(c, [255]), char, text, mediumText, longText
integer, bigInteger, smallInteger, tinyInteger, unsignedInteger (and Big, Small, Tiny)
float, double, decimal(c, [8], [2]), boolean
date, dateTime, dateTimeTz, time, timestamp, timestampTz
json, jsonb, uuid, binary, enumeration(c, [...]), foreignId, foreignUuid
timestamps(), softDeletes(), rememberToken(), morphs('owner')
Modifiers
nullable(), defaultTo(v), defaultRaw('sql'), useCurrent(), useCurrentOnUpdate()
unique(), index(), primary(), unsigned(), comment('t'), after('c'), change()
keys: constrained([table]), references('id').on('t'), onDelete('cascade'), cascadeOnDelete(), nullOnDelete()

Change a table: schema.table('t', (Table table) { table.string('a').nullable().change(); table.renameColumn('b', 'c'); table.dropColumn('d'); }).

See Migration and Schema.

Transactions

int? id = await QueryBuilder.transaction(() async {
  Blog blog = await Blog().create(<String, dynamic>{'title': 'a'});
  await QueryBuilder.table('blog_info').insert(<String, dynamic>{'blog_id': blog.id});
  return blog.id;
}); // throw inside = rollback

await QueryBuilder.transaction(() async {
  Blog blog = await Blog().lockForUpdate().findOrFail(1); // other writers wait
  blog.title = 'b';
  await blog.save();
});

See Transactions and Row locks.

Cache

await Cache().put('k', 'v', duration: Duration(minutes: 10));
await Cache().get('k', 'default');
await Cache().remember('posts', Duration(minutes: 5), () => Post().all());
await Cache().forget('k');
await Cache().store('redis').increment('hits');
await Cache().tag('users').flush();
CacheLock lock = Cache().store('redis').lock('report', Duration(seconds: 30));

Values must be JSON types. memory is per isolate. See Cache.

Storage

String path = await Storage().putRequestFile('avatars', file); // '/avatars/<uuid>.png'
Uint8List? bytes = await Storage().get(path);
await Storage().exists(path);
await Storage().delete(path);
String url = await Storage().signedUrl(path, expiresIn: Duration(minutes: 10));
await Storage().disk('s3').put('reports', bytes, extension: 'pdf');
return Storage().download(path); // from a controller

See File storage.

Env

@DoxEnv()
abstract class AppEnv {
  static AppEnv load([EnvReader? reader]) => _$AppEnv.load(reader);

  String get appKey;              // APP_KEY, required
  int get appPort => 3000;        // APP_PORT, default 3000
  String? get awsBucket;          // AWS_BUCKET, optional
}

final AppEnv env = AppEnv.load();

A new project has this class in lib/config/env.dart. Use env.appPort in code. After you add a getter, run dart run build_runner build.

Env.get<int>('APP_PORT', 3000) is the low-level way. It reads a key by name. A real env var wins over .env. In .env, a # after a space starts a comment.

See Environment variables and Env reference.

Auth

Auth.initialize(AuthConfig(
  defaultGuard: 'web',
  guards: <String, AuthGuard>{
    'web': AuthGuard(
      driver: JwtAuthDriver(secret: SecretKey(env.appKey), expiresIn: Duration(days: 7)),
      provider: AuthProvider(model: () => User()),
    ),
  },
));

String? token = await Auth().attempt(<String, dynamic>{'email': e, 'password': p});
String? token2 = Auth().login(user);
user.password = await Hash.makeAsync(password); // bcrypt in another isolate
bool ok = await Hash.verifyAsync(password, hash);

Protect a route with AuthMiddleware(). Client sends Authorization: Bearer <token>. AuthMiddleware(optional: true) lets guests through too. GuestMiddleware(redirectTo: '/dashboard') is for login pages.

See Authentication and Hashing.

Socket.IO

class ChatNamespace extends NamespaceHandler {
  @override
  String get path => '/chat';

  @override
  void onConnection(Socket socket) {
    socket.join('lobby');
    socket.on('chat', onChat);
  }

  void onChat(SocketEvent event) {
    event.socket.to('lobby').emit('chat', event.data);
    event.ack('ok');
  }
}

Log users in at connect time with SocketAuth(findUser).handle in middleware. The user is in socket.user.

Turn it on with a SocketIoProvider (extend it in lib/). With many isolates, clients use transports: ['websocket']. Broadcasts reach every isolate with no Redis; many servers need the Redis adapter.

See Socket.IO.

OpenAPI

OpenApiConfig openApi = OpenApiConfig(
  info: Info(title: 'Blog API', version: '1.0.0'),
  registry: openApiRegistry, // lib/generated/openapi.g.dart
);

Docs at /docs, /openapi.json, /openapi.yaml. On only when APP_ENV is development or local, unless enabled is set. Not set means production. @Security.bearer(format: 'API key') above a middleware class shows its login scheme. Change one route with @Summary, @Tags, @Request(T), @Response(200, T), @ApiIgnore().

See OpenAPI.

Testing

import 'package:dox_core/testing.dart';
import 'package:dox_orm/testing.dart';

late DoxTestClient client;
setUpAll(() async => client = await DoxTestClient.start(appConfig));
setUp(refreshDatabase); // empty every table before each test
tearDownAll(() async => client.close());

test('ping', () async {
  (await client.get('/api/ping')).expectStatus(200).expectBody('pong');
  (await client.withToken(token).post('/api/posts', body: {...})).expectStatus(201);
});
Fake Code
cache Dox().cache = CacheManager(CacheConfig(defaultStore: 'memory', stores: {...}))
storage Disk fake = Storage.manager.fake('s3'); then Storage.manager.restore('s3')
mail ArrayTransport mail = Mail.fake(); then mail.assertSent(), mail.assertQueued(), mail.assertNothingSent(), and Mail.restore()
queue QueueFake queue = Queue.fake(); then queue.assertPushed<T>(), and Queue.restore()
env AppEnv.load(EnvReader(environment: {...}, dotEnv: {...}))
DTO await CreateBlog.schema.safeValidateMap(<String, dynamic>{...})

Run with dart test --concurrency=1 when files share a port or tables.

See Testing.