# Dox documentation Source: https://dartondox.dev/latest/ --- # Getting Started Source: https://dartondox.dev/latest/
Doxie, the Dox mascot # Fast backends
in Dart

Everything your Dart backend needs.

Dox is a web backend framework for Dart, like Laravel. Routing, an ORM, migrations, validation, auth and a CLI come in one place. It runs one isolate per CPU core and builds to a small native binary.

[Build your first API](tutorial/index.md){ .md-button .md-button--primary } [GitHub](https://github.com/dartondox/dox){ .md-button } [Blog](/blog/){ .md-button }

```dart Route.get('/blogs', BlogController.index); // (1)! enum Status { active, draft } @Dto() // (2)! class BlogFilter { final Status status; // ?status=active, else 422 const BlogFilter({required this.status}); static const DtoSchema schema = $BlogFilterDto; // (3)! } class BlogController { static Future> index( BlogFilter query, // (4)! ) async { return Blog() .where('status', query.status.name) .get(); // (5)! } } ``` 1. A route points at a static method. No reflection, no controller registry. 2. `@Dto()` marks an input class. `build_runner` generates the checks from the fields and their types. 3. The generated schema. The same schema feeds the OpenAPI docs. 4. Ask for the DTO as a parameter. Dox reads the query (or the body), checks it, and gives you a typed object. Bad input gets a `422` before your code runs. 5. The ORM. The same code runs on Postgres, MySQL and SQLite. Postgres and MySQL use native Rust drivers.
## How fast

Ahead of Rust hyper. 1.3× Bun. 2.2× Node. 4.9× plain Dart.

A full Dox app answers GET /, with the rules of the-benchmarker. Requests per second on two CPU cores, higher is better.

req/s p99 RAM
Doxnative server 638,067fastest 0.7 ms 19 MB
Rusthyper 585,6581.09× slower 0.7 ms 13 MB
BunBun.serve 501,5291.27× slower 1.0 ms 40 MB
Nodenode:http 286,8472.2× slower 1.7 ms 200 MB
Gonet/http 242,0432.6× slower 3.3 ms 36 MB
dart:ioHttpServer 129,7964.9× slower 3.8 ms 75 MB

Dox · Rust 1.99 · Bun 1.4 · Node 26 · Go 1.26 · Dart 3.13 · 2 cores · 256 connections · wrkAll results

Your code is plain Dart. The speed comes from the native server: it runs on the isolate's own thread, so a request never waits for another thread. See [Performance](digging-deeper/performance.md) for every route and how it was run. ## Run it in two minutes You need [Dart](https://dart.dev/get-dart) 3.13 or newer. Nothing else: the starter project uses SQLite, so there is no database to install. ```bash dart pub global activate dox # install the CLI dox create my_app # download the starter project cd my_app dart pub get dox s # start the server ``` Open a second terminal and call the first route: ```bash curl localhost:3000/ping ``` ```text pong ``` That is a Dox app. Keep going with the [tutorial](tutorial/index.md): it adds a model, a database table, validation, login and a Docker image, one short step at a time. !!! note "The CLI is not found?" `dart pub global activate` puts `dox` in `$HOME/.pub-cache/bin`. Add that folder to your `PATH`. See [Installation](getting-started/installation.md). ## Pick your path | You are... | Start here | Then | | --- | --- | --- | | New to Dox | [Tutorial: build a blog API](tutorial/index.md) | [Routing](the-basic/routing.md), [Request](the-basic/request.md), [Validation](the-basic/validation.md) | | Coming from Laravel | [Coming from Laravel](concepts/coming-from-laravel.md) | [How Dox works](concepts/how-dox-works.md) | | Looking for one answer | [Guides](guides/index.md) (uploads, auth, Docker, pagination...) | [Reference](reference/index.md) | | Going to production | [Deployment](digging-deeper/deployment.md) | [Docker](guides/docker.md), [Troubleshooting](reference/troubleshooting.md) | ## What you get
- :material-lightning-bolt: **Fast by default** A Rust HTTP server, one isolate per CPU core and streamed bodies. `dox build` makes one native binary. See [Performance](digging-deeper/performance.md). - :material-database-outline: **ORM and query builder** Models, relations, migrations and transactions for Postgres, MySQL/MariaDB and SQLite. Postgres and MySQL use native Rust drivers. See [Database](database/index.md). - :material-check-decagram-outline: **Typed validation** Write a `@Dto` class. Dox checks the input and gives your controller a typed object. See [Validation](the-basic/validation.md). - :material-file-document-outline: **OpenAPI** OpenAPI 3.1 docs and a Scalar docs page, made from your routes and DTOs. See [`dox_openapi`](packages/openapi.md). - :material-folder-outline: **File storage** Local disk and S3 behind one API, with signed URLs. See [`dox_storage`](packages/storage.md). - :material-cached: **Cache** Memory, file and Redis cache with tags and locks. See [`dox_cache`](packages/cache.md). - :material-access-point: **Real time** [Socket.IO](packages/socketio.md), with Redis for many servers. - :material-shield-lock-outline: **Security** JWT [authentication](security/authentication.md), [CORS](security/cors.md), [encryption](security/encryption.md) and [hashing](security/hashing.md). - :material-console: **CLI** Create projects, controllers, models and migrations. Run, build and migrate. See [CLI reference](reference/cli.md).
## Get help - Found a bug or have a question? Open an [issue on GitHub](https://github.com/dartondox/dox/issues). - Using an AI assistant? Give it [llms-full.txt](https://dartondox.dev/llms-full.txt): all of these docs as one plain text file. [llms.txt](https://dartondox.dev/llms.txt) is the page list. - Want to help? Read [Contribute](community/contribute.md). --- # Installation Source: https://dartondox.dev/latest/getting-started/installation/ # Installation This page shows how to install the Dox CLI, make a new project and start the server. Dox needs **Dart 3.13** or newer. ## Install the CLI ```bash dart pub global activate dox ``` Check that it works: ```bash dox version ``` !!! warning "`dox: command not found`" The shell can not find the CLI when the pub `bin` folder is not in your `PATH`. Add this line to `~/.bashrc` or `~/.zshrc`, then open a new terminal: ```bash export PATH="$PATH":"$HOME/.pub-cache/bin" ``` ## Create a new project ```bash dox create new_blog ``` `dox create` downloads the newest release of [dox-sample](https://github.com/dartondox/dox-sample), the starter project. To get one exact release, add `--version` with a release tag of dox-sample. It also makes your `.env` file from `.env.example`, with a new random `APP_KEY`. Then get the packages: ```bash cd new_blog dart pub get ``` The starter project uses SQLite, so there is no database to install. The file `db/dox.sqlite` is created at the first start. To use Postgres or MySQL instead, change `DB_CONNECTION` and the `DB_*` values in `.env`. See [Database](../database/index.md). !!! info "Add Dox to an existing Dart project" Add the packages you need with `dart pub add`: ```bash dart pub add dox_core dox_orm dox_migration dart pub add dev:dox_builder dev:build_runner ``` ## Start the server ```bash dox s ``` `dox s` does two things: - It runs `dart run build_runner watch`, so generated files stay up to date. - It runs `dart run bin/server.dart`, and restarts it when a `.dart` file changes. To skip the builder, use `dox s --ignore-build-runner`. To start the server without the CLI, run `dart run bin/server.dart`. At each start it also runs the new migrations in `db/migration`. The server uses the port in `APP_PORT` in the `.env` file (`3000` by default). Check it: ```bash curl localhost:3000/ping ``` ```text pong ``` ## Start the server with Docker ```bash docker compose up -d --build ``` This starts the app, a queue worker and a Postgres database. The app and the worker always use the Postgres service. They set `DB_CONNECTION` and `DB_NAME` themselves. The database port is open on this machine only (`127.0.0.1`), not on the network. To start only the database and run the app with `dox s`, use `docker compose up -d postgres`. Then set `DB_CONNECTION=postgres` and `DB_NAME=dox` in `.env`. !!! tip "Production" Do not use `dox s` or `dart run` in production. Build a native binary with `dox build`. See [Deployment](../digging-deeper/deployment.md). ## Run the code generator Dox makes code for models and DTOs with `build_runner`. `dox s` already runs it. To run it alone: ```bash dox build_runner:watch # runs again on every file change dox build_runner:build # runs once ``` ## Packages | Package | What it does | | --- | --- | | `dox` | the CLI | | `dox_core` | HTTP server, routing, requests, responses, validation | | `dox_orm` | query builder and models | | `dox_migration` | database migrations | | `dox_auth` | JWT authentication | | `dox_builder`, `dox_annotation` | code generation for models, DTOs and the container | | `dox_validation` | [typed validation schemas](../packages/validation.md) | | `dox_openapi` | [OpenAPI docs](../packages/openapi.md) | | `dox_storage` | [file storage: local, S3](../packages/storage.md) | | `dox_cache` | [cache: memory, file, Redis](../packages/cache.md) | | `dox_socketio` | [Socket.IO server](../packages/socketio.md) | | `dox_queue` | [background jobs](../digging-deeper/queue.md) | | `dox_scheduler` | [scheduled tasks](../digging-deeper/scheduler.md) | | `dox_mailer` | [send email](../digging-deeper/mail.md) | | `dox_view` | [HTML views](../the-basic/views.md) | | `dox_inertia` | [Inertia.js pages](../the-basic/inertia.md) | | `dox_http_native` | [native HTTP server](../digging-deeper/native-server.md) (Rust) | | `dox_postgres_native`, `dox_mysql_native` | native drivers for [Postgres](../database/postgres-native.md) and [MySQL/MariaDB](../database/mysql-native.md) | --- # Build a blog API Source: https://dartondox.dev/latest/tutorial/ # Build a blog API This tutorial takes you from an empty folder to a small blog API that runs as one native binary. Every step has the full code and a `curl` command to check it. The finished project is on GitHub: [examples/blog-api](https://github.com/dartondox/dox/tree/main/examples/blog-api). CI builds it and runs its tests on every push, so the code on these pages keeps working. ## What you will build A JSON API for blog posts. | Method | Path | Who | What it does | | --- | --- | --- | --- | | `GET` | `/api/posts` | anyone | list all posts | | `GET` | `/api/posts/{id}` | anyone | one post, or `404` | | `POST` | `/api/posts` | logged in user | make a post (`201`, or `422` on bad input) | | `PUT` | `/api/posts/{id}` | logged in user | change a post | | `POST` | `/api/auth/register` | anyone | make an account, get a token | | `POST` | `/api/auth/login` | anyone | log in, get a token | | `GET` | `/api/auth/user` | logged in user | the user of the token | ## What you need - Dart **3.13** or newer: `dart --version`. - `curl`, to call the API. - About 15 minutes. You do not need Docker or a database server. The app uses SQLite, which is one file in your project. ## The steps 1. [Create the project](01-create-project.md) 2. [Model and migration](02-model-and-migration.md) 3. [Routes and controller](03-routes-and-controller.md) 4. [Validation](04-validation.md) 5. [Login with dox_auth](05-auth.md) 6. [Test](06-test.md) 7. [Build and deploy](07-build-and-deploy.md) ## The finished project Files that you make or change in this tutorial are marked with `*`. The template has more files (queue, scheduler, storage). This tree shows the ones that matter here. ```text blog/ ├── bin/ │ ├── migrate.dart runs the dox migration:* commands │ ├── server.dart runs the migrations, starts the server │ └── queue.dart, schedule.dart, seed.dart ├── db/migration/ │ ├── ..._create_posts_table.dart * │ ├── ..._create_users_table.dart │ └── migrations.dart the list of migrations (Dox writes it) ├── lib/ │ ├── app/http/controllers/ │ │ ├── auth.controller.dart * │ │ └── post.controller.dart * │ ├── app/dtos/ │ │ ├── login_user/login_user.dto.dart * │ │ ├── register_user/register_user.dto.dart * │ │ └── save_post/save_post.dto.dart * │ ├── app/models/ │ │ ├── post/post.model.dart * │ │ └── user/user.model.dart │ ├── config/ │ │ ├── app.dart * adds the DTO list │ │ ├── database.dart │ │ └── env.dart the .env keys, typed │ ├── routes/api.dart * │ ├── providers/ │ │ ├── auth_provider.dart * │ │ └── orm_provider.dart │ └── generated/ │ ├── container.g.dart * generated │ └── dtos.g.dart * generated ├── test/http_test.dart * ├── .env made by dox create ├── Dockerfile └── pubspec.yaml * adds dox_auth ``` Start with [step 1: create the project](01-create-project.md). --- # Step 1: Create the project Source: https://dartondox.dev/latest/tutorial/01-create-project/ # Step 1: Create the project This step makes a new Dox project, points it at SQLite, starts the dev server and calls the first route. ## 1. Install the CLI and make the project ```bash dart pub global activate dox dox create blog cd blog dart pub get ``` `dox create` downloads the [dox-sample](https://github.com/dartondox/dox-sample) template from GitHub and names the package `blog`. So every import in this tutorial starts with `package:blog/`. ## 2. Use SQLite `dox create` already made `.env` with a new random `APP_KEY`. The template already uses SQLite: ```ini title=".env" APP_PORT=3000 APP_KEY= DB_CONNECTION=sqlite DB_NAME=db/dox.sqlite ``` Dox makes the file `db/dox.sqlite` at the first start. SQLite does not read the other `DB_*` lines. The app reads these keys through the typed class in `lib/config/env.dart`. See [Environment variables](../digging-deeper/env.md). !!! note "The folder must exist" Dox makes the database file when it is missing, but not its folder. If you set `DB_NAME=storage/blog.sqlite`, run `mkdir storage` first. ## 3. Start the server ```bash dox s ``` `dox s` runs the code generator and the server. It restarts the server when you save a `.dart` file. The first start is slower, because it compiles the SQLite library. At the first start it also runs the migrations of the template. The last line is a JSON log line with this message: ```text Server started at http://[::]:3000 with 8 isolate (native) ``` Open it at `http://localhost:3000`. The number of isolates is the number of CPU cores. ## Check it In a second terminal: ```bash curl -i http://localhost:3000/api/ping ``` ```text HTTP/1.1 200 OK content-type: text/plain; charset=utf-8 ... pong ``` The route is in `lib/routes/api.dart`. The `ApiRouter` has the prefix `api`, so `/ping` is served at `/api/ping`. ## Next [Step 2: Model and migration](02-model-and-migration.md) --- # Step 2: Model and migration Source: https://dartondox.dev/latest/tutorial/02-model-and-migration/ # Step 2: Model and migration This step makes the `posts` table with a Dart migration and a `Post` model for it. ## 1. Make the model and the migration Stop `dox s` first with `Ctrl+C`. `dox s` restarts the server when a new file appears, and the server runs new migrations at start. So it would run the new migration before you add the columns. ```bash dox make:model Post -m ``` This makes two files and updates one: | File | What it is | | --- | --- | | `lib/app/models/post/post.model.dart` | the model (new) | | `db/migration/_create_posts_table.dart` | the migration (new) | | `db/migration/migrations.dart` | the list of all Dart migrations. Dox writes it. Do not edit it. | The template already has `bin/migrate.dart`. The `dox migration:*` commands run it. ## 2. Add the columns to the migration Open the new file in `db/migration`. Add `title` and `body`: ```dart title="db/migration/2026_10_22_205345_create_posts_table.dart" // ignore_for_file: file_names import 'package:dox_migration/dox_migration.dart'; class CreatePostsTable extends Migration { @override Future up(Schema schema) async { await schema.create('posts', (Table table) { table.id(); table.string('title'); table.text('body'); table.timestamps(); }); } @override Future down(Schema schema) async { await schema.dropIfExists('posts'); } } ``` - `table.id()` makes an auto increment primary key on every database. - `table.timestamps()` makes `created_at` and `updated_at`. The model fills them. ## 3. Add the fields to the model ```dart title="lib/app/models/post/post.model.dart" import 'package:dox_orm/dox_orm.dart'; part 'post.model.g.dart'; @DoxModel(table: 'posts') class Post extends PostGenerator { @override List get hidden => []; @Column() String? title; @Column() String? body; } ``` Every column field is nullable. A new `Post()` has no values yet. `id`, `createdAt` and `updatedAt` come from `PostGenerator`. You do not declare them. ## 4. Run the migrations on server start Nothing to change here. The template's `bin/server.dart` already runs the migrations before the server starts: ```dart title="bin/server.dart" import '../db/migration/migrations.dart'; void main() async { Dox().initialize(appConfig); await Migrator( migrations: migrations, config: databaseConfig.getConnectionConfig(), ).migrate(); await Dox().startServer(); } ``` This also works in the native binary of [step 7](07-build-and-deploy.md). ## 5. Run the migration and the generator ```bash dox migration:run ``` ```text Migrating: 2026_10_22_205345_create_posts_table Migrated: 2026_10_22_205345_create_posts_table ``` !!! tip "The migration ran too early?" If `dox s` was still running, the `posts` table may already exist without `title` and `body`. Run `dox migration:rollback`, then `dox migration:run` again. The model needs its generated file `post.model.g.dart`. `dox s` makes it while it runs. If `dox s` is not running, run the generator once: ```bash dart run build_runner build ``` ## Check it ```bash dox migration:status ``` ```text Migration Batch Status 2023_07_16_131937700_create_users_table 1 Ran 2026_10_25_000000000_create_queue_tables 1 Ran 2026_10_22_205345_create_posts_table 2 Ran ``` The first two come with the template. The server ran them at the first start in step 1. Now start the server again with `dox s`. ## Next [Step 3: Routes and controller](03-routes-and-controller.md) --- # Step 3: Routes and controller Source: https://dartondox.dev/latest/tutorial/03-routes-and-controller/ # Step 3: Routes and controller This step adds a resource controller and the routes to list, create and show posts. ## 1. Make the controller ```bash dox make:controller Post -r ``` `-r` makes a resource controller with the seven CRUD methods. Replace the file with this. It keeps only `index`, `store` and `show`: ```dart title="lib/app/http/controllers/post.controller.dart" import 'package:blog/app/models/post/post.model.dart'; import 'package:dox_core/dox_core.dart'; @Inject() class PostController { /// GET /api/posts Future> index(DoxRequest req) async { return Post().all(); } /// POST /api/posts Future store(DoxRequest req) async { Post post = Post() ..title = req.input('title') ..body = req.input('body'); await post.save(); return response(post).statusCode(201); } /// GET /api/posts/{id} Future show(int id) async { Post? post = await Post().find(id); if (post == null) { throw NotFoundHttpException(); } return post; } } ``` How it works: - `@Inject()` puts the controller in the container. Dox makes it for you. `dox s` runs `build_runner`, which adds it to `lib/generated/container.g.dart`. A new project already has `container: registerContainer` in `lib/config/app.dart`, so you change nothing there. - A controller returns a model or a list of models. Dox sends it as JSON. - `response(post).statusCode(201)` sets the status code. - `show(int id)` gets the `{id}` from the URL. `/api/posts/abc` answers `404`, because `abc` is not an `int`. - `req.input('title')` reads the body as it is. It is not checked yet. [Step 4](04-validation.md) fixes that. ## 2. Add the routes ```dart title="lib/routes/api.dart" import 'package:blog/app/http/controllers/api.controller.dart'; import 'package:blog/app/http/controllers/post.controller.dart'; import 'package:dox_core/dox_core.dart'; class ApiRouter extends Router { @override String get prefix => 'api'; @override List get middleware => []; @override void register() { Route.get('/ping', (ApiController c) => c.pong); Route.resource('posts', PostController); } } ``` `PostController` is the class. Dox gets the controller from the container. You do not add the router to a list. Dox finds every `Router` class by itself. `Route.resource` makes one route for each method the controller has: | Method | Path | Controller method | | --- | --- | --- | | `GET` | `/api/posts` | `index` | | `POST` | `/api/posts` | `store` | | `GET` | `/api/posts/{id}` | `show` | The controller has no `update` or `destroy`, so those routes are not made. ## 3. Start the server ```bash dox s ``` If it is already running, it restarts by itself when you save a file. ## Check it Make a post: ```bash curl -i -X POST http://localhost:3000/api/posts \ -H 'Content-Type: application/json' \ -d '{"title":"Hello Dox","body":"My first post."}' ``` ```text HTTP/1.1 201 Created content-type: application/json; charset=utf-8 ... {"id":1,"title":"Hello Dox","body":"My first post.","created_at":"2026-10-22T13:58:14.062215Z","updated_at":"2026-10-22T13:58:14.062215Z"} ``` List the posts: ```bash curl http://localhost:3000/api/posts ``` ```json [{"id":1,"title":"Hello Dox","body":"My first post.","created_at":"2026-10-22T13:58:14.062215Z","updated_at":"2026-10-22T13:58:14.062215Z"}] ``` Show one post: ```bash curl http://localhost:3000/api/posts/1 ``` ```json {"id":1,"title":"Hello Dox","body":"My first post.","created_at":"2026-10-22T13:58:14.062215Z","updated_at":"2026-10-22T13:58:14.062215Z"} ``` A post that does not exist: ```bash curl -i http://localhost:3000/api/posts/99 ``` ```text HTTP/1.1 404 Not Found ... {"code":"not_found","message":"Not Found"} ``` ## Next [Step 4: Validation](04-validation.md) --- # Step 4: Validation Source: https://dartondox.dev/latest/tutorial/04-validation/ # Step 4: Validation This step checks the input of create and update with a `@Dto` class. Bad input gets a `422` answer, and the controller does not run. ## 1. Make the DTO ```bash dox make:dto SavePost ``` One class is enough for create and update, because both need the same fields. Replace the file with this: ```dart title="lib/app/dtos/save_post/save_post.dto.dart" import 'package:dox_core/dox_core.dart'; part 'save_post.dto.g.dart'; /// Typed, checked input. Run `dox build_runner:build` after a change. @Dto() class SavePost { @Trim() @Length(min: 3, max: 100) final String title; @Trim() @MinLength(10) final String body; const SavePost({required this.title, required this.body}); static const DtoSchema schema = $SavePostDto; } ``` - A field that is not nullable and has no default is required. - The rules run from top to bottom. `@Trim()` removes spaces first. An empty string then counts as missing. - See [Validation](../the-basic/validation.md) for all rules. ## 2. Give the DTO list to the app The generator writes a list of all DTOs to `lib/generated/dtos.g.dart`. Dox needs this list, because it does not use reflection. Add two lines to `lib/config/app.dart`: ```dart title="lib/config/app.dart" import 'package:blog/generated/dtos.g.dart'; AppConfig appConfig = AppConfig( /// DTOs (generated list in lib/generated/dtos.g.dart) dtos: dtos, // ... the rest stays the same ); ``` ## 3. Use the DTO in the controller Ask for `SavePost` as a parameter. Dox checks the body and gives you a typed object. This also adds `update`, so `PUT /api/posts/{id}` works: ```dart title="lib/app/http/controllers/post.controller.dart" import 'package:blog/app/dtos/save_post/save_post.dto.dart'; import 'package:blog/app/models/post/post.model.dart'; import 'package:dox_core/dox_core.dart'; @Inject() class PostController { /// GET /api/posts Future> index(DoxRequest req) async { return Post().all(); } /// POST /api/posts Future store(SavePost body) async { Post post = Post() ..title = body.title ..body = body.body; await post.save(); return response(post).statusCode(201); } /// GET /api/posts/{id} Future show(int id) async { return _find(id); } /// PUT /api/posts/{id} Future update(int id, SavePost body) async { Post post = await _find(id); post ..title = body.title ..body = body.body; await post.save(); return post; } Future _find(int id) async { Post? post = await Post().find(id); if (post == null) { throw NotFoundHttpException(); } return post; } } ``` `Route.resource` in `lib/routes/api.dart` sees the new `update` method and adds `PUT` and `PATCH /api/posts/{id}`. You change nothing there. ## 4. Run the generator `dox s` runs it for you. Without `dox s`: ```bash dart run build_runner build ``` It writes `lib/app/dtos/save_post/save_post.dto.g.dart` and `lib/generated/dtos.g.dart`. ## Check it A title that is too short, and a body that is too short: ```bash curl -i -X POST http://localhost:3000/api/posts \ -H 'Content-Type: application/json' \ -d '{"title":"Hi","body":"Short"}' ``` ```text HTTP/1.1 422 Unprocessable Entity content-type: application/json; charset=utf-8 ... {"title":"The title must be between 3 and 100 characters","body":"The body must be at least 10 characters"} ``` An empty body: ```bash curl -X POST http://localhost:3000/api/posts \ -H 'Content-Type: application/json' -d '{}' ``` ```json {"title":"The title is required","body":"The body is required"} ``` Good input still gives `201`: ```bash curl -i -X POST http://localhost:3000/api/posts \ -H 'Content-Type: application/json' \ -d '{"title":"Second post","body":"Validation works now."}' ``` ```text HTTP/1.1 201 Created ``` Update the first post: ```bash curl -X PUT http://localhost:3000/api/posts/1 \ -H 'Content-Type: application/json' \ -d '{"title":"Hello Dox (edited)","body":"My first post, edited."}' ``` ```json {"id":1,"title":"Hello Dox (edited)","body":"My first post, edited.","created_at":"2026-10-22T13:58:14.062215Z","updated_at":"2026-10-22T13:59:13.539886Z"} ``` ## Next [Step 5: Login with dox_auth](05-auth.md) --- # Step 5: Login with dox_auth Source: https://dartondox.dev/latest/tutorial/05-auth/ # Step 5: Login with dox_auth This step adds register and login with a JWT token, and only lets a logged in user create or change posts. ## 1. Add the package ```bash dart pub add dox_auth ``` ## 2. Make the users table The template already has a `User` model in `lib/app/models/user/user.model.dart`: ```dart title="lib/app/models/user/user.model.dart" import 'package:dox_orm/dox_orm.dart'; part 'user.model.g.dart'; @DoxModel(table: 'users', softDelete: true) class User extends UserGenerator { @override List get hidden => ['password']; @Column() String? name; @Column() String? email; @Column() String? password; } ``` `password` is in `hidden`, so it is never in the JSON. This matters: the token holds the user as JSON, and anyone can read a JWT. The `users` table already exists. The template has its migration, and the server ran it at the first start in [step 1](01-create-project.md): ```dart title="db/migration/2023_07_16_131937700_create_users_table.dart" class CreateUsersTable extends Migration { @override Future up(Schema schema) async { await schema.create('users', (Table table) { table.id(); table.string('name'); table.string('email').unique(); table.string('password'); table.softDeletes(); table.timestamps(); }); } // ... } ``` `table.softDeletes()` makes the `deleted_at` column that `softDelete: true` needs. `email` is unique, so a second register with the same email fails. ## 3. Make the DTOs ```bash dox make:dto RegisterUser dox make:dto LoginUser ``` ```dart title="lib/app/dtos/register_user/register_user.dto.dart" import 'package:dox_core/dox_core.dart'; import 'package:dox_orm/dox_orm.dart' hide Schema; part 'register_user.dto.g.dart'; @Dto() class RegisterUser { @Trim() @Length(min: 2, max: 50) final String name; @Trim() @Lowercase() @Email() @Unique('users', column: 'email') final String email; @MinLength(8) final String password; const RegisterUser({ required this.name, required this.email, required this.password, }); static const DtoSchema schema = $RegisterUserDto; } ``` `@Unique` comes from `dox_orm`. It reads the database, so it runs after the other rules. `dox_orm` also has a class named `Schema`, so the import hides it. ```dart title="lib/app/dtos/login_user/login_user.dto.dart" import 'package:dox_core/dox_core.dart'; part 'login_user.dto.g.dart'; @Dto() class LoginUser { @Trim() @Lowercase() @Email() final String email; final String password; const LoginUser({required this.email, required this.password}); static const DtoSchema schema = $LoginUserDto; } ``` ## 4. Set up the auth provider A provider runs once in every isolate at start. This one tells `dox_auth` which model to use and how to sign tokens: ```dart title="lib/providers/auth_provider.dart" import 'package:blog/app/models/user/user.model.dart'; import 'package:blog/config/env.dart'; import 'package:dox_auth/dox_auth.dart'; import 'package:dox_core/dox_core.dart'; /// Auth provider /// -------------------------- /// Sets up JWT login with the User model. /// Tokens are signed with APP_KEY and last 7 days. class AppAuthProvider extends DoxProvider { @override void setup() { Auth.initialize( AuthConfig( defaultGuard: 'api', guards: { 'api': AuthGuard( driver: JwtAuthDriver( secret: SecretKey(env.appKey), expiresIn: const Duration(days: 7), ), provider: AuthProvider(model: () => User()), ), }, ), ); } } ``` Dox finds the provider by itself. Run `dart run build_runner build` and it is in `lib/generated/container.g.dart`. You do not add it to a list. ## 5. Write the auth controller ```dart title="lib/app/http/controllers/auth.controller.dart" import 'package:blog/app/dtos/login_user/login_user.dto.dart'; import 'package:blog/app/dtos/register_user/register_user.dto.dart'; import 'package:blog/app/models/user/user.model.dart'; import 'package:dox_auth/dox_auth.dart'; import 'package:dox_core/dox_core.dart'; @Inject() class AuthController { /// POST /api/auth/register Future register(RegisterUser body) async { User user = User() ..name = body.name ..email = body.email ..password = await Hash.makeAsync(body.password); await user.save(); String? token = Auth().login(user); return response({ 'token': token, 'user': user, }).statusCode(201); } /// POST /api/auth/login Future> login(LoginUser body) async { Auth auth = Auth(); String? token = await auth.attempt({ 'email': body.email, 'password': body.password, }); if (token == null) { throw UnAuthorizedException(message: 'Wrong email or password'); } return {'token': token, 'user': auth.user()}; } /// GET /api/auth/user User? user(DoxRequest req) { return req.auth?.user(); } } ``` - `Hash.makeAsync()` makes a bcrypt hash. It runs in another isolate, so other requests do not wait. Never save a plain password. - `Auth().attempt()` finds the user by email and checks the password. It gives a token, or `null`. - `Auth().login(user)` gives a token without a password check. Register uses it, because the user was just made. ## 6. Protect the write routes Replace `Route.resource` with single routes. The routes inside `Route.middleware` need a valid token: ```dart title="lib/routes/api.dart" import 'package:blog/app/http/controllers/api.controller.dart'; import 'package:blog/app/http/controllers/auth.controller.dart'; import 'package:blog/app/http/controllers/post.controller.dart'; import 'package:dox_auth/dox_auth.dart'; import 'package:dox_core/dox_core.dart'; class ApiRouter extends Router { @override String get prefix => 'api'; @override List get middleware => []; @override void register() { Route.get('/ping', (ApiController c) => c.pong); // Anyone can read posts. Route.get('/posts', (PostController c) => c.index); Route.get('/posts/{id}', (PostController c) => c.show); // Register and log in. Route.post('/auth/register', (AuthController c) => c.register); Route.post('/auth/login', (AuthController c) => c.login); // Only a logged in user can write posts. Route.middleware([AuthMiddleware()], () { Route.get('/auth/user', (AuthController c) => c.user); Route.post('/posts', (PostController c) => c.store); Route.put('/posts/{id}', (PostController c) => c.update); }); } } ``` `AuthMiddleware` reads the `Authorization: Bearer ` header and loads the user. When the token is missing or wrong, it answers `401 Authentication failed`. On a public route, `AuthMiddleware(optional: true)` loads the user when there is a good token, and lets guests through. ## 7. Run the generator ```bash dart run build_runner build ``` `dox s` runs it for you. There is no new migration in this step, because the `users` table already exists. ## Check it Register: ```bash curl -i -X POST http://localhost:3000/api/auth/register \ -H 'Content-Type: application/json' \ -d '{"name":"Ann","email":"ann@example.com","password":"secret123"}' ``` ```text HTTP/1.1 201 Created content-type: application/json; charset=utf-8 ... {"token":"eyJhbGciOiJIUzI1NiIs...","user":{"id":1,"name":"Ann","email":"ann@example.com","created_at":"2026-10-22T14:01:07.827769Z","updated_at":"2026-10-22T14:01:07.827769Z"}} ``` The same email again gives `422`: ```json {"email":"The email has already been taken"} ``` A wrong password gives `401`: ```bash curl -i -X POST http://localhost:3000/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"ann@example.com","password":"wrong"}' ``` ```text HTTP/1.1 401 Unauthorized ... {"code":"unauthorized","message":"Wrong email or password"} ``` Log in and keep the token in a shell variable: ```bash TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"ann@example.com","password":"secret123"}' \ | sed -E 's/.*"token":"([^"]+)".*/\1/') echo $TOKEN ``` A post without a token is refused: ```bash curl -i -X POST http://localhost:3000/api/posts \ -H 'Content-Type: application/json' \ -d '{"title":"Third post","body":"Written with a token."}' ``` ```text HTTP/1.1 401 Unauthorized ... {"code":"unauthorized","message":"Authentication failed"} ``` With the token it works: ```bash curl -i -X POST http://localhost:3000/api/posts \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"title":"Third post","body":"Written with a token."}' ``` ```text HTTP/1.1 201 Created ... {"id":3,"title":"Third post","body":"Written with a token.","created_at":"2026-10-22T14:01:08.094211Z","updated_at":"2026-10-22T14:01:08.094211Z"} ``` Who am I: ```bash curl http://localhost:3000/api/auth/user -H "Authorization: Bearer $TOKEN" ``` ```json {"id":1,"name":"Ann","email":"ann@example.com","created_at":"2026-10-22T14:01:07.827769Z","updated_at":"2026-10-22T14:01:07.827769Z"} ``` No `password` in the answer, because it is in `hidden`. ## Next [Step 6: Test](06-test.md) --- # Step 6: Test Source: https://dartondox.dev/latest/tutorial/06-test/ # Step 6: Test This step writes one HTTP test for the list route and runs it. ## 1. Write the test The template has `test/http_test.dart`. Replace it with this: ```dart title="test/http_test.dart" import 'package:blog/config/app.dart'; import 'package:blog/config/database.dart'; import 'package:dox_core/testing.dart'; import 'package:dox_migration/dox_migration.dart'; import 'package:test/test.dart'; import '../db/migration/migrations.dart'; void main() { late DoxTestClient client; setUpAll(() async { await Migrator( migrations: migrations, config: databaseConfig.getConnectionConfig(), ).migrate(); client = await DoxTestClient.start(appConfig); }); tearDownAll(() async { await client.close(); }); test('GET /api/posts gives a list', () async { TestResponse res = await client.get('/api/posts'); res.expectStatus(200).expectJson(isA>()); }); } ``` - `DoxTestClient.start` runs the app inside the test, on a free port. It does not clash with `dox s`. - It runs the migrations first, so the test also works on an empty database. - `package:test` is already in `dev_dependencies` of the template. ## Check it ```bash dart test test/http_test.dart ``` ```text 00:01 +1: All tests passed! ``` !!! tip "Use another database for tests" The test uses the `.env` file, so it reads and writes `db/dox.sqlite`. To keep test data out of it, make a `.env.testing` file with `DB_NAME=test.sqlite`. `dart test` reads it on top of `.env`. ## Next [Step 7: Build and deploy](07-build-and-deploy.md) --- # Step 7: Build and deploy Source: https://dartondox.dev/latest/tutorial/07-build-and-deploy/ # Step 7: Build and deploy This step compiles the app to one native binary, runs it, and shows the `Dockerfile` of the project. !!! warning "Never run production with `dox s` or `dart run`" They use the JIT compiler. It needs about 25 times more memory and starts slower. Build a native (AOT) binary for production. ## 1. Build the binary ```bash dox build ``` ```text Built with build_runner/aot in 1s; wrote 0 outputs. Running build hooks...Running link hooks... Generated: /.../blog/build/bundle/bin/server [Dox] Built build/bundle. Run it with: cd build/bundle && ./bin/server ``` `dox build` runs the code generator, then `dart build cli`. The result is in `build/bundle`: | Path | What it is | | --- | --- | | `bin/server` | the binary, about 10 MB. It needs no Dart SDK. | | `bin/queue` | the [queue worker](../digging-deeper/queue.md). Run it as its own process. | | `bin/schedule` | the [scheduler](../digging-deeper/scheduler.md). Run it as its own process. | | `lib/` | native libraries from build hooks, like `libsqlite3`. Keep it next to `bin/`. | | `db/` | your migrations. Your local SQLite file is not copied. | | `.env.example` | a copy of the example env file. | ## 2. Run the binary The binary reads `.env` from the folder you start it in. Copy yours there. The `db` folder for the SQLite file is already in `build/bundle`. ```bash cd build/bundle cp ../../.env .env ./bin/server ``` ```text Migrating: 2023_07_16_131937700_create_users_table Migrated: 2023_07_16_131937700_create_users_table Migrating: 2026_10_25_000000000_create_queue_tables Migrated: 2026_10_25_000000000_create_queue_tables Migrating: 2026_10_22_205345_create_posts_table Migrated: 2026_10_22_205345_create_posts_table ... "message":"Server started at http://[::]:3000 with 8 isolate (native)" ... ``` The migrations run again, because `db/dox.sqlite` is a new file in `build/bundle`. The migration list is compiled into the binary, so no source code is needed. On a server you can skip `.env` and set real environment variables. They win over `.env`. ## Check it In a second terminal: ```bash curl http://localhost:3000/api/ping curl http://localhost:3000/api/posts ``` ```text pong [] ``` The list is empty, because this is a new database file. Stop the server with `Ctrl+C`. ## 3. Docker The project has a `Dockerfile`. It builds the same bundle and copies it to an empty (`scratch`) image: ```dockerfile title="Dockerfile" FROM dart:stable AS build # Resolve dependencies first, so this layer is cached. WORKDIR /app COPY pubspec.* ./ RUN dart pub get COPY . . RUN dart pub get --offline RUN dart run build_runner build --delete-conflicting-outputs # `dart build cli` is AOT like `dart compile exe`, and it also runs # build hooks. Native libraries go to build/bundle/lib. RUN dart build cli --target bin/server.dart --output build # The queue worker and the scheduler are binaries too. They run as their # own processes: `/app/bin/queue work` and `/app/bin/schedule work`. RUN dart build cli --target bin/queue.dart --output build/queue \ && cp build/queue/bundle/bin/queue build/bundle/bin/ \ && dart build cli --target bin/schedule.dart --output build/schedule \ && cp build/schedule/bundle/bin/schedule build/bundle/bin/ # Lay out the final file tree: # /app/bin/server, /app/bin/queue, /app/bin/schedule, /app/lib (native libraries), /app/db/migration, # /app/storage and a writable /tmp (uploads bigger than 1 MB are # streamed to a temp file). RUN mkdir -p /out/app/storage /out/tmp \ && chmod 1777 /out/tmp \ && cp -r build/bundle/. /out/app/ \ && cp -r db /out/app/db \ # the native HTTP server (Rust) needs libgcc_s, which /runtime/ does not have && LIBGCC=$(find / -name libgcc_s.so.1 -path '*lib*' | head -n 1) \ && mkdir -p /out$(dirname $LIBGCC) && cp $LIBGCC /out$LIBGCC # Minimal serving image: the AOT runtime files from the dart image # (`/runtime/`) plus our bundle. No Dart SDK, no source code. FROM scratch COPY --from=build /runtime/ / COPY --from=build /out/ / WORKDIR /app # Config comes from env variables. The image has no .env file, # so secrets stay out of it. EXPOSE 3000 CMD ["/app/bin/server"] ``` Build and run it. The image has no `.env` file, so pass the config as environment variables. `APP_KEY` must be exactly 32 characters. The app does not start with a shorter or longer key. ```bash docker build -t blog . docker run -p 3000:3000 \ -e APP_KEY=$(openssl rand -hex 16) \ -e DB_CONNECTION=sqlite \ -e DB_NAME=storage/blog.sqlite \ blog ``` !!! note "SQLite in Docker" The database file lives inside the container. It is lost when the container is removed. Mount a volume for it: `-v blog-data:/app/storage`. For a busy app, use Postgres or MySQL. See [Database](../database/index.md). ## Where to go next - [Deployment](../digging-deeper/deployment.md): HTTPS with a proxy, systemd, many machines. - [Model](../database/model/index.md): relationships and events. - [Serializer](../the-basic/serializer.md): change the JSON shape of a response. - [Authentication](../security/authentication.md): all options of `dox_auth`. - [OpenAPI](../packages/openapi.md): API docs from your routes and DTOs. --- # How Dox works Source: https://dartondox.dev/latest/concepts/how-dox-works/ # How Dox works This page explains what happens inside Dox when a request comes in, how isolates work and what each folder of an app is for. ## The life of a request Dox has two parts: - A native server, written in Rust (`dox_http_native`). It owns the sockets and parses HTTP. - Your app, written in Dart. Routes, middleware, DTOs, controllers and the ORM all run here. A request goes through these steps: 1. Rust accepts the connection and reads the request. It parses the HTTP request line, the headers and the body. A bad request gets `400`. Headers over 16 KB get `431`. A body over the size limit gets `413`. Dart never sees these requests. 2. Rust hands the requests to Dart in a batch. One message holds many requests. 3. Dart finds the route. It matches the method, the `Host` (for domain routes) and the path. A plain path is a map lookup. A path with `{params}` is a pattern match. No match gives `404`. A CORS preflight (`OPTIONS`) gets `204`. 4. Dart adds the CORS headers of the route. 5. Dart reads the body, only when the request has a `Content-Type`. A request without a body skips this step. 6. Dart runs the middleware list of the route, in this order: global middleware, router middleware, group middleware, route middleware. A middleware returns the request to go on. Anything else is sent as the response, and the controller does not run. 7. Dart fills the controller parameters. Path params are converted first. A value that does not fit its type (`abc` for `int id`) gets `404`. Then the DTO is read and checked. Bad input gets `422`. A body that is not JSON gets `400`. A content type Dox can not read gets `415`. 8. The controller runs and returns a value. 9. The `responseHandler` from `AppConfig` runs, when you set one. It can change every response. 10. Dox turns the value into the response: a `String` is text, a `Map` or model is JSON, a `File` is streamed. 11. Dart sends the answers of the batch back to Rust in one call. Rust writes the bytes to the socket. When a step throws, Dox catches the error. An `IHttpException` (like `NotFoundHttpException`) gives its own status. Any other error gives `500` and goes to the `report` method of the `ExceptionHandler` in `AppConfig`. ```mermaid sequenceDiagram participant C as Client participant R as Rust (dox_http_native) participant D as Dart isolate participant M as Middleware participant K as Controller C->>R: TCP connection, HTTP request R->>R: parse request, check size limits R->>D: batch of requests (one message) D->>D: router: method, host, path D->>D: CORS headers, read body D->>M: global, router, group, route middleware M-->>D: request (go on) or a response D->>D: path params, DTO check D->>K: call with typed parameters K-->>D: return value D->>D: responseHandler, build the response D->>R: batch of answers (one call) R->>C: bytes ``` !!! note "No reflection" Dox reads the parameter types of every controller once, at start. It does not use `dart:mirrors`. DTOs come from a generated list (`lib/generated/dtos.g.dart`). This is why `dox build` can make a native (AOT) binary. ### Where things are decided | Step | Code | What it decides | | --- | --- | --- | | Route match | `lib/routes/*.dart`, `Route.get(...)` | which controller runs | | Middleware | `AppConfig(globalMiddleware:)`, `Router.middleware`, `Route.middleware(...)`, the route list | go on, or answer early | | Input | a `@Dto` class in `lib/app/dtos//` | what the controller gets, or `422` | | Output | the return value, `response(...)`, `responseHandler` | status, headers, body | | Errors | `IHttpException`, `AppConfig(exceptionHandler:)` | the error response, logging | See [Routing](../the-basic/routing.md), [Middleware](../the-basic/middleware.md), [Validation](../the-basic/validation.md) and [Response](../the-basic/response.md). ## Isolates Dart runs code on isolates. An isolate is like a thread, but it has its own memory. Dox starts one isolate per CPU core (`Platform.numberOfProcessors`). Every isolate runs the full app. ### One port, many isolates Every isolate runs its own HTTP server on the same port. The operating system spreads the connections. | System | Native server | | --- | --- | | Linux | one socket per isolate (`SO_REUSEPORT`) | | macOS | the isolates share one socket | | Windows | only one isolate serves HTTP | Each isolate does its own socket work on its own thread. See [Native server](../digging-deeper/native-server.md#how-it-works). ### What each isolate gets Before it starts an isolate, Dox copies three things to it: - the `AppConfig`, - the list of providers, - the routes and route binders (`Route.bind`) you added before `startServer()`. Then each isolate does this, once: 1. It runs `setup()` of every provider, in list order. This is where a database pool is opened. 2. It registers the routes of your routers. 3. It reads the parameter types of every controller. 4. It starts its HTTP server. Nothing in this list runs per request. Because the config and the providers are copied, they must not hold open sockets or pools. Open these in `setup()`. See [Providers](../digging-deeper/providers.md). ### Each isolate has its own memory - A static value or a singleton exists once per isolate. A change in one isolate is not seen in the others. - The `memory` cache store is per isolate. Use the `redis` store to share cache data. - To share data between isolates, use the database, Redis or files. - `Dox().isolateId` tells you which isolate runs the code. The first one is `1`. ### When an isolate crashes - An error in a request is caught. Dox answers `500` and keeps going. - An async error outside a request is caught and passed to the `ExceptionHandler`. The isolate keeps running. - When a worker isolate still dies, Dox prints `[dox] isolate N exited, restarting` and starts a new one after 1 second. - When the first (main) isolate dies, the process exits with a non-zero code. A supervisor (Docker `restart:`, systemd `Restart=always`) must start it again. ### How many isolates Set `totalIsolate` in `AppConfig`, or `APP_TOTAL_ISOLATE` in `.env`. In code, `Dox().totalIsolate(2)` before `startServer()` wins over the config. Use `1` in tests. See [Isolate](../digging-deeper/isolate.md). ## Where the database pools live The ORM is started in a provider (`ORMProvider`). Every isolate runs `setup()`, so every isolate opens a pool. The native drivers live in Rust. There, isolates that open the same settings get the same pool. | Driver | Per isolate | Per process | | --- | --- | --- | | Postgres native (`dox_postgres_native`) | nothing | one pool of `maxConnectionCount` connections per settings, and one Rust runtime (`extra['threads']`, default `1`) | | MySQL native (`dox_mysql_native`) | nothing | one pool of `maxConnectionCount` connections per settings, and one Rust runtime | | SQLite | one connection | nothing shared | So for Postgres and MySQL, the app opens at most `maxConnectionCount` connections, for any number of isolates. The pool size is always your choice. Set it below the `max_connections` of the database. The pool closes when the last isolate that uses it closes it. See [Native Postgres driver](../database/postgres-native.md#options). Postgres and MySQL have only the native drivers. When the native library does not load, the app stops with an error that says why and how to fix it. See [Database](../database/index.md). ## The layout of a Dox app `dox create my_app` copies the [dox-sample](https://github.com/dartondox/dox-sample) template. It looks like this: ```text my_app/ ├── bin/ │ ├── server.dart # starts the app │ ├── migrate.dart # runs the migrations (dox migration:*) │ ├── seed.dart # runs the seeders (dox db:seed) │ ├── queue.dart # the queue worker (dox queue:*) │ └── schedule.dart # the scheduler (dox schedule:*) ├── db/ │ ├── factories/ # model factories, *.factory.dart │ ├── migration/ # migrations (.dart with a registry, or .sql) │ └── seeders/ # seeders, *.seeder.dart ├── lib/ │ ├── app/ │ │ ├── http/ │ │ │ └── controllers/ # *.controller.dart │ │ ├── jobs/ # queue jobs, *.job.dart │ │ └── models/ # one folder per model, *.model.dart │ ├── config/ │ │ ├── app.dart # AppConfig: port, isolates, middleware │ │ ├── cache.dart # cache stores │ │ ├── cors.dart │ │ ├── database.dart # DatabaseConfig: connections │ │ ├── env.dart # typed env class (@DoxEnv) │ │ ├── logger.dart │ │ ├── queue.dart # QueueConfig │ │ ├── schedule.dart # ScheduleConfig and your tasks │ │ └── storage.dart # storage disks │ ├── routes/ │ │ ├── api.dart # ApiRouter, prefix `api` │ │ └── web.dart # WebRouter │ ├── providers/ # one DoxProvider per file, runs in every isolate │ │ ├── orm_provider.dart # opens the database │ │ ├── queue_provider.dart │ │ ├── schedule_provider.dart │ │ └── storage_provider.dart │ └── generated/ # made by build_runner, do not edit │ ├── container.g.dart # @Inject classes, routers, providers │ ├── jobs.g.dart # the list of queue jobs │ └── openapi.g.dart # the OpenAPI docs ├── test/ ├── .env # local settings, not in git. `dox create` makes it ├── .env.example ├── Dockerfile ├── docker-compose.yml └── pubspec.yaml ``` The `dox make:*` commands add more folders when you need them: - `dox make:dto` adds `lib/app/dtos//.dto.dart`. Then `build_runner` writes `lib/generated/dtos.g.dart`, the list of DTOs. - `dox make:middleware` adds `lib/app/http/middleware/`. - `dox make:serializer` adds `lib/app/serializers/`. The app makes `storage/` when it writes files (the local disk, the file cache, uploads). | Folder | What goes there | | --- | --- | | `bin/` | `server.dart` calls `Dox().initialize(appConfig)`, runs the migrations, then `Dox().startServer()`. The other files are the entry points that the `dox migration:*`, `db:seed`, `queue:*` and `schedule:*` commands run. | | `db/` | Your migrations, factories and seeders. Dart migrations are listed in `db/migration/migrations.dart`, which the CLI writes. | | `lib/app/http/` | Everything about HTTP: controllers and middleware. | | `lib/app/serializers/` | Serializers. They are not only for HTTP: MCP and websocket use them too. | | `lib/app/dtos/` | One folder per DTO: typed and checked input. | | `lib/app/jobs/` | Queue jobs. | | `lib/app/models/` | One folder per model. `build_runner` writes the `.g.dart` file next to it. | | `lib/config/` | One file per config object. `app.dart` puts them together in `AppConfig`. | | `lib/routes/` | One `Router` class per file. Dox finds each router by itself. | | `lib/providers/` | Classes that extend `DoxProvider`. Their `setup()` runs in every isolate. Dox finds each provider by itself. | | `lib/generated/` | Generated files. Do not edit them. Run `dox build_runner:build` to make them again. | | `storage/` | Files the app writes: uploads, the file cache, SQLite files. | `build_runner` with `dox_builder` makes the model classes, the DTO checks, the typed env class and the OpenAPI docs. --- # Coming from Laravel Source: https://dartondox.dev/latest/concepts/coming-from-laravel/ # Coming from Laravel This page is for Laravel developers: it shows the Laravel way and the Dox way side by side. The Laravel code is Laravel 11. The biggest changes are in [What is different](#what-is-different). ## Routing | Laravel | Dox | | --- | --- | | `Route::get('/ping', fn () => 'pong');` | `Route.get('/ping', (DoxRequest req) => 'pong');` | | `Route::post('/blogs', [BlogController::class, 'store']);` | `Route.post('/blogs', (BlogController c) => c.store);` | | `Route::get('/blogs/{id}', ...)` and `function show(int $id)` | `Route.get('/blogs/{id}', ...)` and `show(int id)` | | `Route::get('/posts/{page?}', ...)` | `Route.get('/posts/{page?}', (int? page) => ...)` | | `->where('path', '.*')` | `Route.get('/docs/{page*}', (String page) => ...)` | | `->where('id', '[0-9]+')` | `Route.get('/users/{id}', ...).where('id', '[0-9]+')` | | `Route::match(['get', 'post'], '/contact', ...)` | `Route.match(['GET', 'POST'], '/contact', ...)` | | `Route::any('/webhook', ...)` | `Route.any('/webhook', ...)` | | `Route::redirect('/here', '/there')` | `Route.redirect('/here', '/there')` | | `Route::permanentRedirect('/here', '/there')` | `Route.permanentRedirect('/here', '/there')` | | `Route::fallback(...)` | `Route.fallback(...)` | | `Route::prefix('blogs')->group(function () { ... });` | `Route.group('/blogs', () { ... });` | | `Route::middleware('auth')->group(...)` | `Route.middleware([AuthMiddleware()], () { ... });` | | `Route::domain('admin.app.test')->group(...)` | `Route.domain('admin.app.test', () { ... });` | | `Route::resource('blogs', BlogController::class);` | `Route.resource('blogs', BlogController);` | | `Route::view('/welcome', 'welcome')` | `Route.get('/welcome', (DoxRequest req) => view(req, 'welcome'))` with `view()` of `dox_view`. Dox `Route.view` is the WebDAV `VIEW` method, not a template. | | `routes/api.php` with the `api` prefix | `lib/routes/api.dart`, a `Router` class with `prefix => 'api'` | In Dox, the container makes the controller. Put `@Inject()` above the class. The route picks the method with a typed function. ```dart title="lib/routes/api.dart" class ApiRouter extends Router { @override String get prefix => 'api'; @override List get middleware => []; @override void register() { Route.get('/blogs', (BlogController c) => c.index); Route.resource('users', UserController); } } ``` ## Controllers | Laravel | Dox | | --- | --- | | `php artisan make:controller BlogController` | `dox make:controller Blog` | | `php artisan make:controller BlogController --resource` | `dox make:controller Blog -r` | | `public function show(Request $request, int $id)` | `Future show(DoxRequest req, int id)` | | `public function store(StoreBlogRequest $request)` | `Future store(CreateBlog body)` | | constructor injection by the service container | constructor injection by the container: `@Inject()` above the class | | method injection | filled by type: `DoxRequest`, one `@Dto` class, path params, models, container classes | | implicit route model binding | the same: a `User user` parameter loads `{user}` by its key | Dox fills a parameter by its type, not by its name. Path params go by position. The container is generated code, not reflection. See [Dependency injection](../the-basic/dependency-injection.md). A controller can be a class method, a static method or a plain function. ## Middleware | Laravel | Dox | | --- | --- | | `php artisan make:middleware Logger` | `dox make:middleware Logger` | | `public function handle(Request $request, Closure $next)` | `dynamic handle(IDoxRequest req)` | | `return $next($request);` | `return req;` | | `return response('no', 401);` | `return response('no').statusCode(401);` | | `abort(404);` | `abort(404);` | | `$middleware->append(LogMiddleware::class)` in `bootstrap/app.php` | `AppConfig(globalMiddleware: [LogMiddleware()])` | ```dart title="lib/app/http/middleware/logger.middleware.dart" class LoggerMiddleware implements IDoxMiddleware { @override dynamic handle(IDoxRequest req) { print('${req.method} ${req.uri.path}'); return req; // go on. anything else is the response. } } ``` A plain function `dynamic apiKey(DoxRequest req)` also works as middleware. Dox middleware runs before the controller only. There is no "after" step; use a `responseHandler` for that. ## Requests and validation | Laravel | Dox | | --- | --- | | `php artisan make:request StoreBlogRequest` | `dox make:dto CreateBlog` | | `class StoreBlogRequest extends FormRequest` with `rules()` | `@Dto() class CreateBlog` with annotations on `final` fields | | `'title' => ['required', 'string', 'max:50']` | `@MaxLength(50) final String title;` | | `'age' => ['nullable', 'integer', 'min:18']` | `@Min(18) final int? age;` | | `'email' => ['required', 'email', 'unique:users']` | `@Email() @Unique('users') final String email;` | | `'password' => ['required', 'confirmed', Password::min(8)->mixedCase()]` | `@Confirmed() @Password(mixedCase: true) final String password;` | | `'company' => 'required_if:type,company'` | `@RequiredIf('type', 'company') final String? company;` | | `'pin' => 'digits:4'`, `'meta' => 'json'`, `'day' => 'date_format:Y-m-d'` | `@Digits(4)`, `@Json()`, `@DateFormatted('Y-m-d')` on `String` fields | | `'role' => Rule::in(['admin', 'user'])` | `final Role role;` (a Dart enum) | | `'tags' => ['array', 'max:5']`, `'tags.*' => 'string'` | `@MaxItems(5) final List tags;` | | `$request->validated()` | the DTO object itself, already typed | | `$request->validate([...])` inside a method | `await req.validate()` | | `$request->input('name')` | `req.input('name')` | | `$request->file('avatar')` | a `RequestFile` field in the DTO, or `req.input('avatar')` | | `$request->user()` | `req.auth?.user()` | | `422` with `{"errors": {...}}` | `422` with a flat map `{"email": "...", "items.1.quantity": "..."}` | ```dart title="lib/app/dtos/create_blog/create_blog.dto.dart" @Dto() class CreateBlog { @Length(min: 3, max: 50) final String title; @Min(18) final int? age; // nullable = optional const CreateBlog({required this.title, this.age}); static const DtoSchema schema = $CreateBlogDto; } ``` The rules are checked by generated Dart code, not by rule strings at run time. See [Validation](../the-basic/validation.md). ## Responses | Laravel | Dox | | --- | --- | | `return 'pong';` | `return 'pong';` | | `return ['a' => 1];` | `return {'a': 1};` | | `return $blog;` (JSON) | `return blog;` (JSON from `toJson()`) | | `return response($data, 201)->header('X-A', 'b');` | `return response(data).statusCode(201).header('X-A', 'b');` | | `return response()->json($data);` | `return data;` (a `Map` or `List` is JSON) | | `return response()->file($path);` | `return File(path);` | | `return response()->download($path);` | `return Storage().download(path);` | | `->cookie('theme', 'dark')` | `.cookie(DoxCookie('theme', 'dark'))` | | `abort(404, 'Not found');` | `abort(404, 'Not found');` | | `new BlogResource($blog)` | `BlogSerializer(blog)` | ## Views With [dox_view](../the-basic/views.md), templates end in `.dox.html`. Most Blade tags have the same name. A block ends with `@end`. | Laravel Blade | Dox | | --- | --- | | `{{ $user->name }}`, `{!! $html !!}` | `{{ user.name }}`, `{{{ html }}}` | | `@if ... @endif`, `@foreach ($users as $user)` | `@if ... @end`, `@each(user in users) ... @end` | | `@isset`, `@empty`, `@auth`, `@guest`, `@env`, `@production`, `@once`, `@verbatim` | the same names, closed with `@end` | | `@class`, `@checked`, `@selected`, `@disabled` | the same | | `@include('nav')` | `@include('nav')` | | ``, `$attributes->merge([...])`, `$slot` | `@button(...) ... @end`, `$attributes.merge({...})`, `$slot` | | `@csrf`, `old('email')` | `{{ csrfField() }}`, `old('email')` | ## Eloquent vs Dox Model | Laravel | Dox | | --- | --- | | `php artisan make:model Blog -m` | `dox make:model Blog -m` | | `class Blog extends Model` | `@DoxModel() class Blog extends BlogGenerator` | | `protected $table = 'blogs';` | `@DoxModel(table: 'blogs')` | | `protected $primaryKey = 'uid';` | `@DoxModel(primaryKey: 'uid')` | | `use SoftDeletes;` | `@DoxModel(softDelete: true)` | | `public $timestamps = false;` | `@DoxModel(timestamps: false)` | | `protected $hidden = ['password'];` | `List get hidden => ['password'];` | | `protected $fillable = [...]` | `List get fillable => [...];` | | `$user->forceFill([...])` | `user.forceFill({...})` | | `User::forceCreate([...])` | `await User().forceCreate({...})` | | `Model::unguarded(fn () => ...)` | `await Model.unguarded(() async { ... })` | | `protected $casts = ['meta' => 'array'];` | the field type: `Map? meta;` | | mutator `setSlugAttribute()` | `@Column(beforeSave: makeSlug)` | | accessor `getStatusAttribute()` | `@Column(beforeGet: readStatus)` | | `$appends` with an accessor | `@Computed() String get fullName => ...;` | | `Blog::find(1)` | `await Blog().find(1)` | | `Blog::findOrFail(1)` | `await Blog().findOrFail(1)` (throws `ModelNotFoundException`) | | `->firstOrFail()` | `.firstOrFail()` | | `Blog::where('age', '>', 18)->get()` | `await Blog().where('age', '>', 18).get()` | | `Blog::create([...])` | `await Blog().create({...})` | | `Blog::firstOrCreate([...], [...])` | `await Blog().firstOrCreate({...}, {...})` | | `Blog::firstOrNew([...])` | `await Blog().firstOrNew({...})` | | `Blog::updateOrCreate([...], [...])` | `await Blog().updateOrCreate({...}, {...})` | | `$blog->save()` | `await blog.save()` | | `$blog->delete()` | `await blog.delete()` (returns the deleted rows as an `int`) | | `$blog->increment('views')` | `await blog.increment('views')` | | `$blog->update([...])` | `await blog.update({...})` (only that row, sets `updated_at`, no model events) | | `Blog::withTrashed()` | `Blog().withTrash()` | | `Blog::onlyTrashed()` | `Blog().onlyTrashed()` | | `$blog->touch()` | `await blog.touch()` | | `$blog->replicate()` | `blog.replicate()` | | `$blog->restore()` | `await blog.restore()` | | `Blog::with('posts')->get()` | `await Blog().preload('posts').get()` | | `$blog->load('posts')` | `await blog.$getRelation('posts')` | | `$user->posts()->where(...)->get()` | `user.related('posts')?.where(...).get()` | | `User::whereHas('posts', fn ($q) => ...)` | `User().whereHas('posts', (QueryBuilder q) { ... })` | | `orWhereHas`, `whereDoesntHave`, `orWhereDoesntHave` | the same names | | `User::has('posts', '>=', 3)` | `User().has('posts', '>=', 3)` | | `whereHas('posts.comments')` | `whereHas('posts.comments')` | | `$with = ['posts']` | `@HasMany(Post, eager: true)` | | `hasOne`, `hasMany`, `belongsTo`, `belongsToMany` | `@HasOne`, `@HasMany`, `@BelongsTo`, `@ManyToMany` | | local scope `scopeActive()` | a static method with `@Scope()`, then `Blog().active()` | | global scope | `@Scope(global: true)` | | `creating`, `saved`, `deleting` events | `onCreating()`, `onSaved()`, `onDeleting()` hooks in the model | | `Blog::observe(BlogObserver::class)` | `Model.observe(BlogObserver())` | | `$blog->saveQuietly()` | `await blog.saveQuietly()` | | `Model::withoutEvents(fn)` | `Model.withoutEvents(() async { ... })` | ```dart title="lib/app/models/blog/blog.model.dart" @DoxModel(table: 'blogs', softDelete: true) class Blog extends BlogGenerator { @Column() String? title; @Column(name: 'user_id') int? userId; @BelongsTo(User) User? user; @override Future onCreating() async { title = title?.trim(); } } ``` Every column field is nullable, because a new model has no values yet. Dox has no `$casts` list. Use the field type, a [cast](../database/model/casts.md), `beforeSave` or `beforeGet`. For scopes, see [Scopes](../database/model/scopes.md). See [Model](../database/model/index.md). ## Query builder | Laravel | Dox | | --- | --- | | `DB::table('users')->get()` | `await QueryBuilder.table('users').get()` | | `->where('a', 1)->orWhere('b', 2)` | `.where('a', 1).orWhere('b', 2)` | | `->whereIn('id', [1, 2])` | `.whereIn('id', [1, 2])` | | `->whereIn('id', $subQuery)` | `.whereIn('id', subQuery)` | | `->whereNull('deleted_at')` | `.whereNull('deleted_at')` | | `->where(fn ($q) => $q->where(...)->orWhere(...))` | `.whereGroup((QueryBuilder q) { q.where(...).orWhere(...); })` | | `->orWhere(fn ($q) => ...)` | `.orWhereGroup((QueryBuilder q) { ... })` | | `->whereNot(fn ($q) => ...)` | `.whereNot((QueryBuilder q) { ... })` | | `->whereBetween('age', [18, 60])` | `.whereBetween('age', [18, 60])` | | `->whereColumn('a', '>', 'b')` | `.whereColumn('a', '>', 'b')` | | `->whereLike('name', '%a%')` | `.whereLike('name', '%a%')` | | `->whereExists(fn ($q) => $q->from('post')->...)` | `.whereExists('post', (QueryBuilder> q) { ... })` | | `->whereDate('created_at', '2024-05-01')`, `->whereYear(...)` | `.whereDate('created_at', '2024-05-01')`, `.whereYear(...)` | | `->whereJsonContains('tags', 'a')` | `.whereJsonContains('tags', 'a')` | | `->when($cond, fn ($q) => ...)` | `.when(cond, (QueryBuilder q) { ... })` | | `->whereRaw('lower(name) = ?', [$n])` | `.whereRaw('lower(name) = @n', {'n': n})` | | `->select('id', 'name')` | `.select(['id', 'name'])` | | `->orderBy('name', 'desc')->limit(10)->offset(20)` | `.orderBy('name', 'desc').limit(10).offset(20)` | | `->latest()`, `->oldest()`, `->inRandomOrder()` | `.latest()`, `.oldest()`, `.inRandomOrder()` | | `->distinct()` | `.distinct()` | | `->having('a', '>', 1)`, `->havingRaw(...)` | `.having('a', '>', 1)`, `.havingRaw(...)` | | `->join('posts', 'posts.user_id', '=', 'users.id')` | `.join('posts', 'posts.user_id', 'users.id')` | | `->joinSub($q, 'alias', 'alias.id', '=', 'users.id')` | `.joinSub(q, 'alias', 'alias.id', 'users.id')` | | `->fromSub($q, 'alias')` | `.fromSub(q, 'alias')` | | `->union($q)`, `->unionAll($q)` | `.union(q)`, `.unionAll(q)` | | `->groupBy('age')` | `.groupBy('age')` on `QueryBuilder.table()` (returns maps) | | `->count()` | `await ....count()` | | `->sum('a')`, `->avg('a')`, `->min('a')`, `->max('a')` | `.sum('a')`, `.avg('a')`, `.min('a')`, `.max('a')` | | `->exists()`, `->doesntExist()` | `.hasRows()`, `.hasNoRows()` | | `->pluck('name')` | `.pluck('name')` | | `->pluck('name', 'id')` | `.pluckMap('name', 'id')` | | `->value('email')` | `.valueOf('email')` | | `->chunk(100, fn)`, `->chunkById(100, fn)` | `.chunk(100, (rows) { ... })`, `.chunkById(100, (rows) { ... })` | | `->paginate(10)` | `.paginate(currentPage: 1, perPage: 10)` | | `->simplePaginate(10)` | `.simplePaginate(currentPage: 1, perPage: 10)` | | `->cursorPaginate(10)` | `.cursorPaginate(perPage: 10, cursor: cursor)` | | `->insert([...])` | `.insertMultiple(>[...])` | | `->update([...])` | `.update({...})` (returns the changed rows as an `int`; on a model it keeps `fillable`, use `.forceUpdate()` to skip it) | | `->increment('views', 5)`, `->decrement(...)` | `.increment('views', 5)`, `.decrement(...)` | | `->insertOrIgnore([...])` | `.insertOrIgnore(>[...])` | | `->upsert([...], ['email'], ['name'])` | `.upsert(>[...], ['email'], ['name'])` | | `->delete()` | `.delete()` (returns the deleted rows as an `int`) | | `Blog::query()->delete()` (every row) | `await Blog().whereRaw('1 = 1').delete()`. With no `where`, `Blog().delete()` throws a `StateError`. | | `->lockForUpdate()`, `->sharedLock()` | `.lockForUpdate()`, `.sharedLock()` | | `DB::select('select ...', [$id])` | `QueryBuilder.query('select ... @id', substitutionValues: {'id': id})` | | `DB::transaction(fn)` | `QueryBuilder.transaction(() async { ... })` | | `->toSql()` | `.toSql()` | | `DB::connection('mysql')` | `.withConnection('mysql')` | Values are always bound parameters. Names you pass are quoted. Do not pass user input as a column name or an operator. `exists` and `value` are other names in Dox, because a model already has an `exists` getter, and `value` is a common column name. ## Migrations and schema | Laravel | Dox | | --- | --- | | `php artisan make:migration create_users_table` | `dox make:migration create_users_table` | | `database/migrations/` | `db/migration/` | | `Schema::create('users', function (Blueprint $table) {...})` | `await schema.create('users', (Table table) {...})` | | `$table->id();` | `table.id();` | | `$table->string('email')->unique();` | `table.string('email').unique();` | | `$table->foreignId('user_id')->constrained()->cascadeOnDelete();` | `table.foreignId('user_id').constrained().cascadeOnDelete();` | | `$table->timestamps();` | `table.timestamps();` | | `$table->softDeletes();` | `table.softDeletes();` | | `$table->string('name', 100)->nullable()->change();` | `table.string('name', 100).nullable().change();` | | `Schema::dropIfExists('users');` | `await schema.dropIfExists('users');` | | `php artisan migrate` | `dox migrate` (or `dox migration:run`) | | `php artisan migrate:rollback --step=2` | `dox migration:rollback --step=2` | | `php artisan migrate:reset`, `migrate:refresh`, `migrate:status` | `dox migration:reset`, `migration:refresh`, `migration:status` | | `php artisan migrate:fresh --seed` | `dox migrate:fresh --seed` | | `php artisan db:seed --class=UserSeeder` | `dox db:seed --class=UserSeeder` | | `php artisan make:factory`, `make:seeder` | `dox make:factory`, `dox make:seeder` | ```dart title="db/migration/2026_10_21_120000_create_users_table.dart" class CreateUsersTable extends Migration { @override Future up(Schema schema) async { await schema.create('users', (Table table) { table.id(); table.string('email').unique(); table.timestamps(); }); } @override Future down(Schema schema) async { await schema.dropIfExists('users'); } } ``` Dart can not load files at run time in an AOT binary. So the CLI keeps a list of your migrations in `db/migration/migrations.dart`. You do not edit it. The server can run the migrations at start. See [Migration](../database/migration.md). ## Artisan vs the dox CLI | Laravel | Dox | | --- | --- | | `laravel new blog` | `dox create blog` | | `php artisan serve` | `dox serve` (or `dox s`), with hot restart and `build_runner watch` | | `php artisan make:resource` | `dox make:serializer` | | `php artisan key:generate` | `dox key:generate` | | `php artisan route:list` | not there. Use the OpenAPI docs page at `/docs`. | | `php artisan tinker` | not there | | `php artisan optimize` | `dox build` (one native binary in `build/bundle`) | | `php artisan queue:work` | `dox queue:work` | | `php artisan schedule:work` | `dox schedule:work` | The `make:*` and `migrate` commands are in the tables above. Two commands have no Laravel twin: `dox build_runner:build` makes the generated code, and `dox env:generate` makes the typed env class. See [Dox CLI](../digging-deeper/dox-cli.md). ## Auth | Laravel | Dox | | --- | --- | | `config/auth.php` guards and providers | `Auth.initialize(AuthConfig(guards: ...))` in a provider | | `Auth::attempt(['email' => $e, 'password' => $p])` | `await Auth().attempt({'email': e, 'password': p})` gives a JWT, or `null` | | `Auth::login($user)` | `Auth().login(user)` gives a JWT | | `Auth::user()` | `req.auth?.user()` | | `Auth::id()` | `req.auth?.id()` | | `Auth::guest()` | `req.auth == null` | | `Auth::check()` | `Auth().check()` (the same as `isLoggedIn()`) | | `Auth::once($credentials)` | `await Auth().once(credentials)` gives `true` or `false`, with no token and no session | | `Auth::attempt(...)` for a web page (session) | `await Auth().attemptSession(req, {...})` gives `true` or `false` | | `Auth::logout()` | `Auth().logout(req)` | | `->middleware('auth:sanctum')` | `AuthMiddleware()` from `dox_auth` | | `->middleware('auth:web,api')` | `AuthMiddleware(guards: ['web', 'api'])` | | `->middleware('guest')` | `GuestMiddleware(redirectTo: '/dashboard')` | | `Crypt::encryptString($v)`, `Crypt::decryptString($v)` | `Crypt.encryptString(v)`, `Crypt.decryptString(v)` | | `Hash::make($p)`, `Hash::check($p, $hash)` | `Hash.make(p)`, `Hash.check(p, hash)` (or `Hash.verify`) | For an API, Dox auth is a JWT in the `Authorization` header. For tokens you can revoke, like Sanctum, use `AccessTokenAuthDriver`. `dox auth:tokens-table` makes its `auth_access_tokens` table. For web pages, a guard with `SessionAuthDriver` keeps the login in the session cookie. See [Authentication](../security/authentication.md). ## Cache | Laravel | Dox | | --- | --- | | `config/cache.php` | `lib/config/cache.dart`, `CacheConfig(defaultStore:, stores:)` | | `Cache::put('k', $v, 600)` | `await Cache().put('k', v, duration: Duration(minutes: 10))`. The time is a named `duration:`, not the third value. | | `Cache::many(['a', 'b'])`, `Cache::putMany([...], 600)` | `await Cache().many(['a', 'b'])`, `await Cache().putMany({...}, duration: ...)` | | `Cache::get('k', 'default')` | `await Cache().get('k', 'default')` | | `Cache::remember('k', 600, fn)` | `await Cache().remember('k', Duration(minutes: 10), fn)` | | `Cache::forget('k')` | `await Cache().forget('k')` | | `Cache::store('redis')` | `Cache().store('redis')` | | `Cache::tags(['a'])` | `Cache().tags(['a'])` | | `Cache::lock('k', 10)` | `Cache().lock('k', Duration(seconds: 10))` | | drivers: file, redis, memcached, database, array | drivers: `file`, `redis`, `memory`, `null` | A value must be a JSON type. The `memory` store is per isolate. See [Cache](../digging-deeper/cache.md). ## Storage | Laravel | Dox | | --- | --- | | `config/filesystems.php` disks | `lib/config/storage.dart`, `StorageConfig(defaultDisk:, disks:)`, passed to a `StorageProvider` (`dox_storage`) | | `Storage::put('a.txt', $bytes)` | `await Storage().put('files', bytes, extension: 'txt')` (a uuid file name) | | `$request->file('f')->store('avatars')` | `await file.store('avatars')` or `Storage().putRequestFile('avatars', file)` | | `Storage::get($path)` | `await Storage().get(path)` | | `Storage::exists($path)` | `await Storage().exists(path)` | | `Storage::delete($path)` | `await Storage().delete(path)` | | `Storage::url($path)` | `await Storage().url(path)` | | `Storage::temporaryUrl($path, now()->addMinutes(10))` | `await Storage().signedUrl(path, expiresIn: Duration(minutes: 10))` | | `Storage::disk('s3')` | `Storage().disk('s3')` | | `Storage::download($path)` | `return Storage().download(path);` | | drivers: local, s3, ftp, sftp | drivers: `local`, `s3` (also R2, MinIO, Spaces), `memory` | See [File Storage](../digging-deeper/file-storage.md). ## Config and `.env` | Laravel | Dox | | --- | --- | | `config/*.php` files | `lib/config/*.dart` files, put together in `AppConfig` | | `env('APP_KEY')` | `env.appKey` (a getter in `lib/config/env.dart`) | | `env('APP_PORT', 3000)` | `env.appPort` (the default is in the getter: `int get appPort => 3000;`) | | `config('app.name')` | `env.appName` from the typed env class (`@DoxEnv()`) | | `php artisan config:cache` | not needed. The typed env is read once, at start. | The typed env class gives you an `int` for `APP_PORT` and a build error for a bad type. A missing key stops the app at start, with a list of every missing key. See [Environment Variables](../digging-deeper/env.md). ## Testing | Laravel | Dox | | --- | --- | | `php artisan test` | `dart test` | | `$this->get('/api/ping')->assertStatus(200)` | `(await client.get('/api/ping')).expectStatus(200)` with `DoxTestClient` | | `RefreshDatabase` | run the migrations in `setUpAll`, then `setUp(refreshDatabase)` from `package:dox_orm/testing.dart` | | `Storage::fake('s3')` | `Storage.manager.fake('s3')` | | `Mail::fake()` | `Mail.fake()`, then `mail.assertSent()` and `mail.assertQueued()` | | `Queue::fake()` | `Queue.fake()`, then `queue.assertPushed()` | | `Cache::fake()` | `Dox().cache = CacheManager(CacheConfig(...))` with a `memory` store | | `$this->actingAs($user)` | `client.withToken(token)` with the JWT from `Auth().login(user)` | Dox has no fake HTTP kernel. `DoxTestClient.start(appConfig)` starts the real server inside the test, on a free port, and sends real requests. See [Testing](../digging-deeper/testing.md). ## What is different - **A generated container.** Like Laravel, Dox makes your controllers and injects what the constructor asks for. You add `@Inject()` to the class. `build_runner` writes `lib/generated/container.g.dart` with every registration. You do not register classes by hand in a provider. A singleton is one instance per isolate, not one per request. - **Isolates, not PHP-FPM workers.** One process runs one isolate per CPU core. Each isolate runs your whole app and has its own memory. A static value or a singleton lives once per isolate. A PHP worker starts from zero on each request. A Dox isolate keeps its state between requests, so a memory leak stays. Anything that must be shared goes to the database, Redis or files. See [How Dox works](how-dox-works.md#isolates). - **Code generation, not reflection.** Laravel reads your classes at run time. Dart has no usable reflection in a compiled app. So `build_runner` with `dox_builder` writes the model code, the DTO checks, the env class and the OpenAPI docs into `.g.dart` files. Run `dox build_runner:build` after a change, or let `dox serve` watch for you. - **One native binary.** `dox build` makes an AOT binary in `build/bundle`. There is no interpreter, no `vendor/` folder and no `composer install` on the server. The Docker image is about 11 MB. - **A native HTTP server and native database drivers.** Rust parses HTTP and talks to Postgres and MySQL. Your code stays in Dart. They come ready-built, so you do not need Rust. - **Types everywhere.** A DTO is a class with typed fields. A path param is an `int`. A model field is a `String?`. The compiler finds many mistakes before you run the app. - **Async is explicit.** Database calls return a `Future`. You write `await`. One isolate serves many requests at the same time while it waits for the database. - **JSON API first.** Dox returns text, JSON, files and streams. For HTML pages, add [views](../the-basic/views.md) (`dox_view`) or [Inertia](../the-basic/inertia.md) (`dox_inertia`). ## What is missing today Dox does not have these Laravel features yet: - events and listeners (model events are there) - password reset See [What Dox does not have yet](status-and-roadmap.md#what-dox-does-not-have-yet) for what to use today. What is there, as separate packages: [validation](../packages/validation.md), [OpenAPI docs](../packages/openapi.md), [storage](../packages/storage.md), [cache](../packages/cache.md), [Socket.IO](../packages/socketio.md), [auth](../security/authentication.md), [queues](../digging-deeper/queue.md), the [scheduler](../digging-deeper/scheduler.md), [mail](../digging-deeper/mail.md), [views](../the-basic/views.md) and [Inertia](../the-basic/inertia.md). [Sessions](../the-basic/session.md) are in `dox_core`. --- # Dox and the other Dart backends Source: https://dartondox.dev/latest/concepts/dart-frameworks/ # Dox and the other Dart backends This page compares Dox with four other ways to build a backend in Dart: Dart Frog, Serverpod, Shelf (with `shelf_router`) and Alfred. It lists facts only. Each project is good at different things. !!! note "Facts can change" The facts about the other projects come from their own docs, READMEs and pub.dev pages, read on 2026-10-22. Projects change. Check their docs before you decide. Where this page is not sure, the table says "see their docs". ## What each one is for - **Dox** is a full backend framework, in the style of Laravel. It has routes, controllers, typed DTOs, an ORM, migrations, auth, cache, storage and a CLI. - **Dart Frog** is a small backend framework. It is built on Shelf. Routes come from the files in the `routes/` folder. It describes itself as "a fast, minimalistic backend framework for Dart". - **Serverpod** is a full-stack framework for Flutter. You write endpoint methods on the server. Serverpod generates a typed Dart client for the Flutter app. It has an ORM, auth, streaming and a hosted service (Serverpod Cloud). - **Shelf** is the request and middleware model from the Dart team. It is a library, not a framework. `shelf_router` adds a router. You pick every other part yourself. - **Alfred** is a small server in the style of Express.js. Routes, middleware, WebSocket and static files are built in. ## Feature table "Not built in" means the project does not ship it. You can still add a package for it. | Feature | Dox | Dart Frog | Serverpod | Shelf + shelf_router | Alfred | | --- | --- | --- | --- | --- | --- | | Routing | Route classes: `Route.get(...)`, groups, resources, domain routes | Routes from files in `routes/`, dynamic and nested routes | Endpoint classes become typed client methods. A separate web server (Relic) has its own routing. | `Router()` with `` patterns, nested routers | Express-like, `:id:int` params with types or regex | | Typed request validation | `@Dto` classes, checks generated at build time, `422` with errors | Not in their feature list (see their docs) | Typed, generated parameters for endpoints. Rules: see their docs. | Not built in | Route params only (type or regex) | | ORM and relations | `dox_orm`: models, has one, has many, belongs to, many to many | Not built in | Built in: filters, joins, one-to-one, one-to-many, many-to-many | Not built in | Not built in | | Migrations | `dox migrate` (SQL or Dart files) | Not built in | Built in, versioned migrations | Not built in | Not built in | | Auth | `dox_auth`: JWT and session guards. Password hashing (`Hash`) is in `dox_core`. | `dart_frog_auth`: Basic, Bearer and Cookie middleware | Built in: email and password, Google, Apple, GitHub, passkeys and more | Not built in | See their docs | | WebSocket | A Socket.IO v4 server (`dox_socketio`) and raw `Route.websocket` | `dart_frog_web_socket` (`webSocketHandler`) | Streaming methods over a managed WebSocket | `shelf_web_socket` | Built in | | OpenAPI | Generated OpenAPI 3.1 (JSON, YAML) and a docs page | On their roadmap, not done | See their docs | See their docs | See their docs | | CLI and code generation | `dox` CLI, `build_runner` for DTOs, models and OpenAPI | `dart_frog` CLI: `create`, `dev` (hot reload), `build` | `serverpod` CLI, generates server and client code | No CLI (`shelf_router_generator` for routes from annotations) | No CLI | | Native binary | `dox build` makes an AOT binary | `dart_frog build` makes a production build with a Dockerfile | Deploy to Serverpod Cloud, Docker or Terraform. Binary: see their docs. | Your app is plain Dart. Compile it like any Dart program. | Your app is plain Dart. Compile it like any Dart program. | | Multi-core | One isolate per CPU core by default | See their docs | One isolate per process. Scale with more processes and server roles. | `serve(..., shared: true)` and your own isolates | Yes, a multi-isolate mode is in their README | | HTTP server | Native server in Rust, also for WebSocket | Shelf on `dart:io` | Relic (their own) for the web server. API server: see their docs. | `dart:io` `HttpServer` (`shelf_io`) | `dart:io` | | Databases | Postgres, MySQL, MariaDB, SQLite | Not built in | Postgres, SQLite (also SQLite on the device). Redis for cache. | Not built in | Not built in | | Code sharing with Flutter | `@Dto` classes in a pure Dart package, see [Share code with Flutter](../guides/share-code-with-flutter.md) | Plain Dart packages. A generated client is marked "in progress" in their feature list. | Generated client package, and shared packages for models | Plain Dart packages | Plain Dart packages | | License | MIT | MIT | BSD-3 for most packages. The main `serverpod` package is SSPL. | BSD-3-Clause | MIT for the core. Some parts BSD. | | First release and maturity | New (2026) | First on pub.dev in 2022, 1.0 about three years ago | First on pub.dev in 2021, now 4.x | First on pub.dev in 2014, 1.0 in 2021 | First on pub.dev in 2021, 1.x | | Maintenance (on 2026-10-22) | Active | Active. Moved to the `dart-frog-dev` group in 2025. Last `dart_frog` release: 1.2.6 (November 2025). | Active. Release 4.0.4 in October 2026. | Active. Published by `tools.dart.dev` (the Dart team). | Last release 1.1.3, about 17 months ago. Unverified uploader. | Sources (checked on 2026-10-22): - Dart Frog: [dart-frog.dev](https://dart-frog.dev), [github.com/dart-frog-dev/dart_frog](https://github.com/dart-frog-dev/dart_frog) (docs in `docs/src/content/docs`, the roadmap and "The Future of Dart Frog"), [pub.dev/packages/dart_frog](https://pub.dev/packages/dart_frog) - Serverpod: [docs.serverpod.dev](https://docs.serverpod.dev), [github.com/serverpod/serverpod](https://github.com/serverpod/serverpod), [github.com/serverpod/serverpod_docs](https://github.com/serverpod/serverpod_docs) (scalability, streaming, shared packages, database connection), [pub.dev/packages/serverpod](https://pub.dev/packages/serverpod) - Shelf: [pub.dev/packages/shelf](https://pub.dev/packages/shelf), [pub.dev/packages/shelf_router](https://pub.dev/packages/shelf_router), [github.com/dart-lang/shelf](https://github.com/dart-lang/shelf), [`serve` API docs](https://pub.dev/documentation/shelf/latest/shelf_io/serve.html) - Alfred: [pub.dev/packages/alfred](https://pub.dev/packages/alfred), [github.com/rknell/alfred](https://github.com/rknell/alfred) ## Speed This page does not compare speed. Dox's own numbers (hello world and a `/db` read) are on [Performance](../digging-deeper/performance.md). To compare, run the same test on your own machine, with each server on the same number of cores. ## When to pick Dox - You want a full framework in one place: routes, typed input, ORM, migrations, auth, cache, storage and OpenAPI docs. - You know Laravel. See [Coming from Laravel](coming-from-laravel.md). - You need MySQL or MariaDB, not only Postgres. - You want typed DTOs with the same rules on the server and in a Dart or Flutter client. See [Share code with Flutter](../guides/share-code-with-flutter.md). - You want an OpenAPI document made from your code, for clients that are not written in Dart. ## When to pick something else - **You want a framework with many years of use.** Dart Frog, Serverpod and Shelf have been out for years. Dox is new. - **You want a hosted service.** Serverpod has Serverpod Cloud. Dox has no hosted service. You deploy it yourself (a binary or Docker). - **Your app is Flutter only, and you want the client made for you.** Serverpod generates a typed client for every endpoint. Dox does not generate a Dart client. It generates an OpenAPI document. - **You want a small tool with few parts.** Dart Frog is simpler. You add only what you need. - **You want to build your own stack.** Shelf gives you the base parts, and the Dart team publishes it. - **You need features Dox does not have yet**, like events and listeners. See [What Dox does not have yet](status-and-roadmap.md#what-dox-does-not-have-yet). --- # Status and roadmap Source: https://dartondox.dev/latest/concepts/status-and-roadmap/ # Status and roadmap This page shows what works in Dox today, what is missing and what is planned. ## Packages What the states mean: - **Stable**: works, has tests and has had real use. - **New**: works and has tests, but it is newer and has had less real use. All packages follow semantic versioning: a breaking change only comes in a new major version. The packages in the main repo share one version number. The other packages live in their own repos and have their own version numbers. See [Contribute](../community/contribute.md#packages-in-other-repos). | Package | What it does | State | Tests | | --- | --- | --- | --- | | `dox_core` | Routes, middleware, controllers, request and response, isolates, config, the IoC container, exception handler, sessions, CSRF, rate limit, security headers, health check, test client | Stable | 528 tests in 61 files. CI runs them on the native server. | | `dox_orm` | Query builder, models, relations, `whereHas`, scopes, casts, `withCount`, transactions, row locks, many to many writes, polymorphic relations, factories, seeders | Stable | 549 tests in 51 files. CI runs them on Postgres, MariaDB, MySQL 8.4 and SQLite. | | `dox_validation` | `@Dto` rules, fluent schemas, JSON Schema, Standard Schema | Stable | 149 tests in 8 files | | `dox_builder` | Code generator for models, DTOs, env classes, the container and OpenAPI | Stable | 86 tests in 5 files | | `dox_annotation` | Annotations used by the generator | Stable | 6 tests in 1 file. Mostly tested through `dox_builder`. | | `dox` (CLI) | `dox create`, `dox serve`, `dox build`, `make:*`, `migration:*`, `db:seed`, `queue:*`, `schedule:*`, package commands | Stable | 132 tests in 12 files | | `dox_migration` | Migrations for Postgres, MySQL, MariaDB and SQLite | Stable | 36 tests in 5 files. CI runs them on each database. | | `dox_auth` | JWT and session auth guards and middleware, access tokens you can revoke, gates and policies | Stable | 201 tests in 8 files | | `dox_cache` | Memory, file and Redis cache, tags, locks | Stable | 94 tests in 4 files, with Redis in CI | | `dox_storage` | Local disk and S3 (and S3-compatible) storage | Stable | 153 tests in 10 files, with MinIO in CI | | `dox_openapi` | OpenAPI 3.1 document and a docs page | Stable | 63 tests in 4 files | | `dox_socketio` | Socket.IO v4 server with a Redis adapter | Stable | 119 tests in 11 files, with Redis and the real `socket.io-client` in CI | | `dox_queue` | Queues and jobs | New | 96 tests in 12 files, with Postgres, MariaDB and Redis in CI | | `dox_scheduler` | Run tasks on a schedule | New | 106 tests in 6 files, with Redis in CI | | [`dox_mcp`](../packages/mcp.md) | MCP server: tools for AI clients over Streamable HTTP, signed sessions | New | 15 tests in 1 file | | `dox_view` | [Views](../the-basic/views.md): `.dox.html` templates with layouts and components | New | 40 tests in 2 files | | [`dox_inertia`](../the-basic/inertia.md) | Inertia.js server side: single page apps with React, Vue or Svelte and Dox controllers | New | 34 tests in 2 files | | `dox_mailer` | Email with SMTP, Amazon SES, Azure, Resend, Mailgun, Postmark and sendmail, mailables, Markdown mail, notifications | New | 122 tests in 12 files, with a fake SMTP server | | `dox_http_native` | Native HTTP/1.1 and WebSocket server in Rust, on the isolate's own thread. Dox has no other server. | New | 65 Dart tests in 4 files, and 32 Rust tests | | `dox_postgres_native` | Native Postgres client (Rust tokio-postgres), the Postgres driver. Has TLS (`sslmode`, `sslRootCert`). | New | 41 tests in 7 files, plus the `dox_orm` tests | | `dox_mysql_native` | Native MySQL and MariaDB client (Rust mysql_async), the MySQL driver. Has TLS. | New | 30 tests in 2 files, plus the `dox_orm` tests | `Hash` (bcrypt password hashing) is in `dox_core`. !!! note "About test coverage" The repos do not measure line coverage. Counts are `test(` calls in each package's `test/` folder. A test inside a loop counts once, so `dart test` can report more tests. The CI setup of the main repo is in `.github/workflows/test.yaml`. The packages in their own repos have their own CI. ## What Dox does not have yet | Feature | Status | What to do today | | --- | --- | --- | | Events and listeners | Not there | Model events are there. See [Model events](../database/model/events.md). | | Password reset | Not there | Make a signed, short-lived token, send it with [Mail](../digging-deeper/mail.md) and check it in a controller. | | Localization | Partly | Validation messages can follow the request locale (`req.locale`). There is no general translation helper. Keep your own message maps. | ## Planned ### Later - Localization The package CHANGELOGs have an `Unreleased` section when changes wait for the next version. ## How to help - Try Dox and open an issue when something breaks or is missing. - Pick an item above and open an issue first, so the design can be discussed. - See [Contribute](../community/contribute.md) to set up the repo and run the tests. --- # Contribute Source: https://dartondox.dev/latest/community/contribute/ # Contribute This page shows how to work on Dox: set up the repo, run the tests, release packages and update the docs. To send a change, fork the repo and open a pull request. ## Setting up local development ### 1. Clone the repo ```bash git clone git@github.com:dartondox/dox.git ``` ### 2. Install melos The repo is a [Dart pub workspace](https://dart.dev/tools/pub/workspaces). The melos config is in the root `pubspec.yaml` (under `melos:`). Use Dart 3.13 or newer. ```bash dart pub global activate melos ``` ### 3. Run melos bootstrap It runs one `dart pub get` at the root. All local packages are linked. There is one `pubspec.lock` for the whole repo. ```bash melos bs ``` ### 4. Useful commands ```bash # Run all tests. # Use `melos run test`. `melos test` is a built-in melos command. melos run test # Analyze and check format (same as CI) melos run analyze melos run format_check ``` ### If the project does not run 1. Remove `pubspec.lock` and `.dart_tool` at the root. 2. Remove any old `pubspec_overrides.yaml` and `pubspec.lock` in `packages/*` (left from melos 3). 3. Run `melos bs` again. ## Release (maintainers) The `Release` GitHub workflow (`.github/workflows/publish.yaml`) publishes all public packages to pub.dev. It runs when you push a tag like `v`. It uses pub.dev automated publishing, so it needs no token or secret. ### Steps The full guide is [RELEASING.md](https://github.com/dartondox/dox/blob/main/RELEASING.md) in the repo. In short: 1. Set the new version in the root `pubspec.yaml`. Then run `bin/update_versions.sh`: it sets this version in every package. Close the `## Unreleased` section of each `CHANGELOG.md`. 2. Merge the release PR, then push the tag `v`. This publishes to pub.dev. 3. Release the matching `dox-sample` template. The native packages (`dox_http_native`, `dox_postgres_native`, `dox_mysql_native`) are not in this repo. See [Native packages](#native-packages) below. `dox_orm`, `dox_migration`, `dox_socketio`, `dox_storage`, `dox_queue`, `dox_scheduler`, `dox_inertia`, `dox_view` and `dox_mailer` are not in this repo either. See [Packages in other repos](#packages-in-other-repos). **New package only:** turn on pub.dev automated publishing for it first. See [One-time setup](#one-time-setup). A new package must first exist on pub.dev, so publish its first version by hand with `dart pub publish`. ### What the workflow does 1. It checks that the tag (without `v`) is the root version, and that every package has this version. If not, it stops. Run `bin/update_versions.sh` and try again. 2. It gets the publish order from `bin/publish_order.py`. Each package comes after the sibling packages it depends on (`dependencies` and `dev_dependencies`). Packages with `publish_to: none` are left out. New packages are picked up by themselves. 3. It publishes each package in this order. After each one, it waits until `dart pub get` can find the new version. To see the order on your machine: ```bash python3 bin/publish_order.py ``` A package is skipped when its version is already on pub.dev. So if a run fails halfway, fix the problem and click "Re-run jobs". It continues where it stopped. ### Dry run Open Actions > Release > "Run workflow". It runs `dart pub publish --dry-run` for every package and publishes nothing. Warnings (for example a missing CHANGELOG entry) show as yellow warnings. They do not fail the run. ### One-time setup Do this once for each published package, and for every new package before its first tag. The list of packages is the output of `python3 bin/publish_order.py`. On pub.dev: 1. Open `https://pub.dev/packages//admin`. 2. Under "Automated publishing", click "Enable publishing from GitHub Actions". 3. Repository: `dartondox/dox`. 4. Tag pattern: `v{{version}}`. 5. Tick "Require GitHub Actions environment" and set the environment to `pub.dev`. 6. Save. On GitHub, in `dartondox/dox` > Settings > Environments, there must be an environment named `pub.dev`. The publish job runs in it. Optional: add yourself as a required reviewer, and allow only tags `v*`. ## Packages in other repos | Package | Repo | | --- | --- | | `dox` (the CLI) | [dartondox/dox-cli](https://github.com/dartondox/dox-cli) | | `dox_orm`, `dox_migration` | [dartondox/dox-orm](https://github.com/dartondox/dox-orm) | | `dox_auth` | [dartondox/dox-auth](https://github.com/dartondox/dox-auth) | | `dox_openapi` | [dartondox/dox-openapi](https://github.com/dartondox/dox-openapi) | | `dox_validation` | [dartondox/dox-validation](https://github.com/dartondox/dox-validation) | | `dox_socketio` | [dartondox/dox-socketio](https://github.com/dartondox/dox-socketio) | | `dox_storage` | [dartondox/dox-storage](https://github.com/dartondox/dox-storage) | | `dox_queue` | [dartondox/dox-queue](https://github.com/dartondox/dox-queue) | | `dox_scheduler` | [dartondox/dox-scheduler](https://github.com/dartondox/dox-scheduler) | | `dox_inertia` | [dartondox/dox-inertia](https://github.com/dartondox/dox-inertia) | | `dox_view` | [dartondox/dox-view](https://github.com/dartondox/dox-view) | | `dox_mailer` | [dartondox/dox-mailer](https://github.com/dartondox/dox-mailer) | The packages here use them from pub.dev. The database tests (Postgres, MySQL, MariaDB, SQLite) run in `dox-orm`. `dox_core` uses `dox_openapi` and `dox_validation`. CI in this repo uses their newest code: `ci/clone-siblings.sh`, then `ci/use-siblings.sh`. To work on one of them and Dox together: 1. Clone the repos next to the Dox repo, like `../dox-orm`. `ci/clone-siblings.sh` clones `dox-orm`, `dox-auth`, `dox-openapi`, `dox-validation`, `dox-socketio`, `dox-storage`, `dox-queue`, `dox-scheduler` and `dox-cli`. Clone `dox-mailer`, `dox-view` and `dox-inertia` by hand when you need them. 2. Add them to the root `pubspec_overrides.yaml` of the Dox repo. Git ignores this file. For `dox_openapi` and `dox_validation`, `ci/use-siblings.sh` writes this file for you. ```yaml dependency_overrides: dox_openapi: path: ../dox-openapi dox_validation: path: ../dox-validation ``` 3. Run `dart pub get`. The apps in `examples/` already use these clones (their `pubspec_overrides.yaml`), so clone them before you run an example. In the other repo, a `pubspec_overrides.yaml` that points to `../dox/packages/...` uses your local Dox packages. Each repo has its own version. Push a `v` tag in that repo to release it. ## Native packages The three native packages live in their own repos: | Package | Repo | | --- | --- | | `dox_http_native` | [dartondox/dox-http-native](https://github.com/dartondox/dox-http-native) | | `dox_postgres_native` | [dartondox/dox-postgres-native](https://github.com/dartondox/dox-postgres-native) | | `dox_mysql_native` | [dartondox/dox-mysql-native](https://github.com/dartondox/dox-mysql-native) | Dox uses them from pub.dev. Their build hooks download ready-built libraries, so you do not need Rust to work on Dox. To work on a native package and Dox together: 1. Clone the package next to the Dox repo, like `../dox-http-native`. 2. Add it to the root `pubspec_overrides.yaml` of the Dox repo. Git ignores this file. ```yaml dependency_overrides: dox_http_native: path: ../dox-http-native ``` 3. Run `dart pub get`. Each native package is released from its own repo, with its own version. Push a `v` tag in that repo. Its CI builds the libraries, uploads them, fills the hashes and publishes the package to pub.dev. Then raise the version constraint in the Dox packages that use it. ## Docs The docs site is https://dartondox.dev. It is built with MkDocs Material and [mike](https://github.com/jimporter/mike). mike keeps one folder per version on the `gh-pages` branch. | URL | What it is | | --- | --- | | `/3.x/...` | 3.x docs, built from `main` | | `/latest/...` | a copy of the newest version (now 3.x) | | `/` | redirects to `/latest/` | | old links like `/the-basic/routing/` | redirect to `/latest/the-basic/routing/` | ### Write and preview ```bash python3 -m venv .venv .venv/bin/pip install -r requirements-docs.txt .venv/bin/mkdocs serve # live preview of this branch .venv/bin/mkdocs build --strict # the same build as CI. Broken links fail. ``` `hooks/docs_hooks.py` runs during the build. It makes the changelog page from `packages/*/CHANGELOG.md`, and writes `llms.txt` and `llms-full.txt`. The `Docs check` workflow (`.github/workflows/docs-check.yaml`) runs on every pull request that changes the docs. It runs three checks: - `mkdocs build --strict` - a spell check: `codespell docs --skip "*.svg,*.png,*.ico,*.css,*.js" --ignore-words .codespell-ignore` - `ci/check-no-tabs.sh`: the docs must not use content tabs ### Deploy - 3.x deploys with each release. The `Docs` workflow (`.github/workflows/docs.yaml`) runs on a `v` tag, the same tag that publishes the packages. So docs for a feature that is not released yet stay off the site. It runs `mike deploy --update-aliases 3.x latest`. - To deploy the docs only (a typo fix, or the docs of a package from another repo that was just released), push a `docs-v` tag on `main`, like `git tag docs-v3.0.1-1 && git push origin docs-v3.0.1-1`. It publishes no package and runs no tests. - After each deploy, the workflow writes small redirect pages for the old root links, the root `index.html`, a `404.html` and the `CNAME` file. So `dartondox.dev` and old links keep working. - The workflow pushes with the built-in `GITHUB_TOKEN`. This works because `gh-pages` is not a protected branch. ### New major version When 4.x starts, deploy 4.x with `mike deploy --update-aliases 4.x latest`. Keep a `v3.x` branch, and add `3.x` to the workflow input, so 3.x can still be fixed. --- # Showcase Source: https://dartondox.dev/latest/community/showcase/ # Showcase This page lists real open source apps built with Dox that you can read, run and copy from. ## Monthly Expense [Monthly Expense](https://github.com/necessarylion/monthly-expense) is an expense tracker for you and the people you share money with. You log expenses, track wallets in many currencies and keep a shopping list. An AI assistant like Claude can add expenses and read summaries for you over MCP. | Part | What it uses | | --- | --- | | API | Dox, with SQLite or Postgres | | Web app | React, Vite, Tailwind and shadcn/ui | | Sign-in | Firebase (Google), checked on every API request | | AI | An MCP server with OAuth sign-in, 44 tools | | Deploy | One Docker image with the web app and a native API binary | What you can learn from it: - **ORM and migrations.** Groups, members, categories, expenses, wallets and budgets as models. The app runs the migrations when it starts. See [Model](../database/model/index.md) and [Migration](../database/migration.md). - **Token auth from another service.** The API checks a Firebase ID token in middleware. It makes the user on the first sign-in. See [Middleware](../the-basic/middleware.md). - **MCP for AI clients.** Each HTTP route has a matching MCP tool with the same input. See [MCP](../packages/mcp.md). - **Scheduled jobs.** See [Scheduler](../digging-deeper/scheduler.md). - **One container in production.** The API serves the built web app and compiles to a native binary. CI runs the tests on SQLite and Postgres, then pushes the image. See [Docker](../guides/docker.md). Run it on your machine: ```bash git clone https://github.com/necessarylion/monthly-expense.git cd monthly-expense ./dev.sh ``` The [README](https://github.com/necessarylion/monthly-expense#readme) lists the Firebase settings and env vars you need first. ## Add your app Built something with Dox? Open a pull request that adds it to this page. Your app must be open source, so readers can see the code. --- # Meet Doxie Source: https://dartondox.dev/latest/community/mascot/ # Meet Doxie ![Doxie, the Dox mascot](../assets/mascot.png){ width="260" } Doxie is the official mascot of Dox. Doxie is a blue dachshund. ## Why a dachshund "Doxie" is a common nickname for a dachshund. It sounds like Dox, so the name came first. A dachshund is small, fast and full of energy. Dox tries to be the same: a small native binary that serves a lot of requests. ## Logo and mascot Dox has a logo and a mascot. They have different jobs. | | Logo | Mascot | | --- | --- | --- | | What | A hexagon with a "D" cut out | Doxie, the blue dachshund | | Where | The docs header, the favicon, the README, pub.dev | The home page, social cards, talks, stickers | | Files | [`logo.svg`](../assets/logo.svg) (white, for dark backgrounds) | [`mascot.png`](../assets/mascot.png) (transparent background) | Use the logo when the space is small, like an icon or an avatar. Doxie has too much detail to read well at 16 or 32 pixels. ## Colors | Name | Hex | Used for | | --- | --- | --- | | Sky blue | `#5CC6FF` | Doxie's body, links in dark mode | | Dart blue | `#0A5FC2` | Doxie's ears and collar, links in light mode | | Navy | `#0B1B36` | The docs header, outlines | ## Use Doxie You are welcome to use Doxie when you write or talk about Dox: blog posts, slides, videos and stickers. Please do not change Doxie's colors, and do not use Doxie to brand another product. --- # Routing Source: https://dartondox.dev/latest/the-basic/routing/ # Routing A route connects a URL and an HTTP method to the code that answers it. This is the path of one request through the router: ```mermaid flowchart LR Q["Request
GET /blogs/42"] --> B["Router
method, host, path"] B -->|no match| N["404"] B -->|match| P["Params as text
req.param"] P --> M["Middleware"] M -->|returns a value| S["Response"] M -->|returns the request| T["Typed params, DTO
int id"] T --> C["Controller"] C --> S ``` ## Where routes live Routes are in the `lib/routes` folder. Each file has one `Router` class. The `prefix` of the router is added in front of all its routes. ```dart title="lib/routes/api.dart" import 'package:dox_core/dox_core.dart'; class ApiRouter extends Router { @override String get prefix => 'api'; @override List get middleware => []; @override void register() { Route.get('/ping', (DoxRequest req) => 'pong'); // GET /api/ping } } ``` Dox finds every `Router` class by itself. It is in `lib/generated/container.g.dart` after `dart run build_runner build`. To pick the order of routers, list them in `routers:` of `AppConfig`. See [Dependency injection: routers and providers](dependency-injection.md#routers-and-providers). The leading `/` in a path is optional. `'ping'` and `'/ping'` are the same route. ## Route methods ```dart Route.get('/blogs', (BlogController c) => c.index); Route.post('/blogs', (BlogController c) => c.store); Route.put('/blogs/{id}', (BlogController c) => c.update); Route.patch('/blogs/{id}', (BlogController c) => c.update); Route.delete('/blogs/{id}', (BlogController c) => c.destroy); ``` !!! info There are also `Route.options`, `Route.copy`, `Route.view`, `Route.link`, `Route.unlink`, `Route.purge`, `Route.lock`, `Route.unlock` and `Route.propfind`. These are HTTP methods. `Route.view` is the WebDAV `VIEW` method. It does not render a template like Laravel `Route::view`. To show a template, use a GET route: `Route.get('welcome', (DoxRequest req) => view(req, 'welcome'))` with `view()` of `dox_view`. `Route.health('/health')` adds a health check route. It answers `200` with `{"status": "ok"}`. `Route.websocket('echo', (WebSocket socket, DoxRequest req) {...})` adds a raw WebSocket route. By default any website can open it from a browser, with the cookies of the user. To check the `Origin`, pass `allowedOrigins: ['https://app.example.com']`, or set `AppConfig(webSocketAllowedOrigins: [...])` for every WebSocket route. Then only pages of the same host and the listed sites can open it. Other origins get `403`. `[]` means the same host only. When a WebSocket route has no check, Dox prints one warning to stderr at start. A message over `maxMessageSize` bytes (default 16 MB) closes the socket with code `1009`. Example: `Route.websocket('chat', handler, maxMessageSize: 64 * 1024)`. A `HEAD` request runs the `GET` route of the same path. The answer has the status and headers of `GET`, with no body. ### More than one method `Route.match` adds the same handler for a list of methods. `Route.any` adds it for `GET`, `POST`, `PUT`, `PATCH` and `DELETE`. ```dart Route.match(['GET', 'POST'], '/contact', (ContactController c) => c.handle); Route.any('/webhook', (WebhookController c) => c.handle); ``` - `HEAD` comes with `GET`, so do not list it. An unknown method throws an `ArgumentError` at start. - `Route.any` does not add `OPTIONS`. So Dox still answers the CORS preflight request for the path. - `.where(...)` and `.timeout(...)` after `Route.match` or `Route.any` work on every route it added. `.name(...)` names the last one. ## Redirect routes `Route.redirect` sends a path to another URL. The status is `302` by default. ```dart Route.redirect('/home', '/'); Route.redirect('/old-blog', '/blog', status: 301); Route.permanentRedirect('/old-pricing', '/pricing'); // 301 ``` The redirect works for the same methods as `Route.any`. - The target is sent as it is. A param like `{id}` in the target is not filled in: `Route.redirect('/u/{id}', '/users/{id}')` sends the client to `/users/{id}`. - The query string of the request is dropped. `/home?page=2` goes to `/`, not `/?page=2`. - For these cases, use a normal route and return `redirect(...)` with the URL you build. ## The handler The second argument is the code that answers the request. It can be: ```dart // a function Route.get('/ping', (DoxRequest req) => 'pong'); // a controller method. The controller comes from the container. Route.get('/ping', (WebController c) => c.pong); @Inject() class WebController { String pong(DoxRequest req) { return 'pong'; } } // a list: middleware first, the controller last Route.get('/ping', [ LoggerMiddleware(), (WebController c) => c.pong, ]); ``` In a list, the last item is the controller. The items before it are [middleware](middleware.md). `(WebController c) => c.pong` picks the method. Dox calls it once per isolate, at start. See [Dependency injection](dependency-injection.md). See [Controller](controller.md) for the parameters a handler can ask for. ## Route params Put a param in `{}`. Ask for it as a typed parameter in the controller. ```dart Route.get('/blogs/{id}', (BlogController c) => c.show); @Inject() class BlogController { Future show(int id) async { return Blog().find(id); } } ``` `/blogs/abc` answers `404`, because `abc` is not an `int`. See [Controller: parameters](controller.md#parameters) for all the rules. ### Optional param `{name?}` can be missing. Use a nullable type. ```dart Route.get('/posts/{page?}', (int? page) { return 'page ${page ?? 1}'; // GET /posts and GET /posts/2 both match }); ``` ### Catch-all param `{name*}` matches the rest of the path, with slashes. ```dart Route.get('/docs/{page*}', (String page) { return page; // GET /docs/a/b/c gives 'a/b/c' }); ``` ### Param rules `.where(param, regex)` lets a route match only when the param text matches the regex. When it does not match, the router tries the next route. When no route matches, the client gets `404`. ```dart Route.get('/users/{id}', (UserController c) => c.show).where('id', '[0-9]+'); Route.get('/users/{name}', (UserController c) => c.byName); // gets the rest Route.get('/files/{type}', handler).where('type', 'png|jpg'); Route.get('/docs/{page}', handler).where('page', '.*'); // matches slashes too ``` - The regex must match the whole param text. You do not need `^` or `$`. A leading `^` and a trailing `$` are dropped. - Use `(?:...)` for a group, not `(...)`. A regex with a `(...)` group throws an `ArgumentError` at start. - A param name that the path does not have throws an `ArgumentError` at start. - Call `.where` more than once for more params. - Routes without `.where` match the same as before. The check costs nothing for them. - The regex checks the param text two times: as it is in the URL, and again after `%` codes are decoded. Both checks must pass. A `%` code for a letter, a digit or `-._~` is decoded before the router sees the path, so `/users/%31` is `/users/1` and matches `[0-9]+`. Other `%` codes stay in the URL text and must pass the second check. So `/files/x%2Fy` does not match `[^/]+`, because `%2F` is `/`. The controller gets the decoded value. In the second check, `.` also matches a new line (`%0A`), so `.*` still matches it. ## Group routes ```dart // by prefix Route.group('/blogs', () { // (1)! Route.get('/', (BlogController c) => c.index); // GET /blogs Route.get('/{id}', (BlogController c) => c.show); // (2)! }); // by domain Route.domain('dartondox.com', () { // (3)! Route.get('/ping', (WebController c) => c.pong); }); // by middleware Route.middleware([AuthMiddleware()], () { // (4)! Route.get('/me', (AuthController c) => c.me); Route.post('/logout', (AuthController c) => c.logout); }); ``` 1. `/blogs` goes in front of every path in the callback. Dox puts one `/` between the parts. `'blogs'` and `'/blogs/'` work the same. 2. Makes `GET /blogs/{id}`. 3. These routes match only when the `Host` header is `dartondox.com`. Other hosts get `404`. 4. `AuthMiddleware` runs before `me` and `logout`. It runs after the global and router middleware. Groups can be nested. `Route.group('blogs', ...)` with `Route.get('all', ...)` makes `/blogs/all`. A group can also add a prefix to the [route names](#named-routes) inside it, with `name:`. To turn CORS on or off for some routes, see [CORS](../security/cors.md). ## Resource routes One line makes the usual CRUD routes for a controller. ```dart Route.resource('blogs', BlogController); ``` `BlogController` is a class from the container. A controller object works too: `Route.resource('blogs', BlogController())`. | Method | Path | Controller method | Route name | | --- | --- | --- | --- | | `GET` | `/blogs` | `index` | `blogs.index` | | `GET` | `/blogs/create` | `create` | `blogs.create` | | `POST` | `/blogs` | `store` | `blogs.store` | | `GET` | `/blogs/{id}` | `show` | `blogs.show` | | `GET` | `/blogs/{id}/edit` | `edit` | `blogs.edit` | | `PUT`, `PATCH` | `/blogs/{id}` | `update` | `blogs.update` | | `DELETE` | `/blogs/{id}` | `destroy` | `blogs.destroy` | The name comes from the path: `/` becomes `.`, and `{params}` are left out. So `Route.resource('users/{user}/posts', ...)` gives `users.posts.index`. Pass `name:` to pick another one: `Route.resource('blogs', BlogController, name: 'posts')`. A method that the controller does not have is skipped. No route is made for it. Any other error from the controller is not hidden. It stops the app at start, so you see it. See [Resource controller](controller.md#resource-controller) to make one with the CLI. ## Forms with PUT, PATCH and DELETE An HTML form can only send GET or POST. To reach a `PUT`, `PATCH` or `DELETE` route, add `MethodOverride` to the global middleware. ```dart AppConfig( globalMiddleware: [const MethodOverride()], ); ``` Then send the form as POST with a `_method` field. ```html
``` - Only a POST request changes, and only to `PUT`, `PATCH` or `DELETE`. Other values are ignored. - `req.method` gives the new method. - `CsrfMiddleware` still checks the request, so send the `_token` field too. - It works only in `globalMiddleware`, not on a router or a route. It is off by default. A client that can not send PUT, like an old proxy or JS client, can send the `X-HTTP-Method-Override` header instead. Turn it on with `header: true`. It is off by default. ```dart AppConfig( globalMiddleware: [const MethodOverride(header: true)], ); ``` The header has the same rules as `_method`. When both are there, the header wins. ## Named routes Give a route a name with `.name()`. Then build its URL from the name, so a path lives in one place. ```dart Route.get('/users/{id}', (UserController c) => c.show).name('users.show'); route('users.show', params: {'id': 5}); // /users/5 route('users.show', params: {'id': 5}, query: {'tab': 'posts'}); // /users/5?tab=posts ``` `route()` gives the path. `Route.url()` does the same. Param values are URL encoded. A missing `{name?}` param is left out. A group can put a prefix in front of the names inside it: ```dart Route.group('admin', () { Route.get('users', (UserController c) => c.index).name('users'); // admin.users }, name: 'admin.'); ``` Dox stops with a clear error when: - two routes have the same name (at start), - `route()` gets a name that no route has, - a `{param}` of the path has no value, or `params` has a key the path does not have. ### Signed routes A signed URL is a named route with a `signature` in its query, so nobody can change it. Use it for links in emails, like "unsubscribe". Protect the route with `ValidateSignature`, and make the URL with `Route.signedUrl`: ```dart Route.get('/unsubscribe/{user}', [ const ValidateSignature(), (UserController c) => c.unsubscribe, ]).name('unsubscribe'); Route.signedUrl('unsubscribe', params: {'user': 5}, expires: Duration(days: 7)); // https://example.com/unsubscribe/5?expires=...&signature=... ``` A changed or expired URL gets `403`. See [Signed URLs](../security/signed-urls.md). ## Route model binding A binder turns a route param into a value before the controller runs, for example a model from the database. Add it with `Route.bind`, before the routes that use it. ```dart Route.bind('user', (String id) => User().find(id)); Route.get('/users/{user}', (DoxRequest req, User user) { return user; }); ``` Every route with a `{user}` param now runs the binder first. The binder gets the raw text from the URL. It returns the value, or `null` when there is none. `null` answers `404`, the same way as a thrown `NotFoundHttpException`. There are two ways to read the value: - Ask for a parameter of the bound type, like `User user`. Dox matches it by type, so it can be at any place in the parameter list. - Read it from the request: `req.bound('user')`. This also works in a controller that only takes `DoxRequest`. Other path params still work next to it: ```dart Route.get('/users/{user}/posts/{page}', (User user, int page) { return user.posts(page); }); ``` The binder runs after the middleware, so an auth middleware can stop the request before any database call. A `@DoxModel` class does not need `Route.bind`. Dox makes a binder for it in `lib/generated/container.g.dart`. See [Dependency injection: route model binding](dependency-injection.md#route-model-binding). `Route.bind` wins when both exist. ## Serve static files `Route.staticFiles(prefix, folder)` serves the files of a folder, like CSS, JavaScript and images. ```dart Route.staticFiles('/', 'public'); Route.staticFiles('build', 'public/build', maxAge: Duration(days: 365)); ``` - Only `GET` and `HEAD` requests read files, and only when no route matches. A route always wins. - `..`, dot files (like `.env` or `.git`) and folders are never sent. They get `404`. - The one dot folder that is sent is `.well-known` at the top of the folder (`public/.well-known/...`). It holds ACME (Let's Encrypt) challenges, `security.txt` and `apple-app-site-association`. Dot files inside it still get `404`. - A file gets a weak `ETag` (from its size and change time) and `Last-Modified`. The browser gets `304` with no body when it already has the file. `If-None-Match` wins over `If-Modified-Since`. Range requests work. With `If-Range`, the Range is used only when it is the file's `Last-Modified` date. Else the whole file is sent. - `maxAge` sends `Cache-Control: public, max-age=...`. Give a long `maxAge` to build files with a hash in the name (like Vite's). - No middleware runs for a file. The global CORS config still applies. - Like other routes, the prefix follows `Route.group`, the router prefix and `Route.domain`. - The folder path is relative to the folder the app starts in. Symlinks in the folder are followed. !!! warning Never save user uploads in this folder. An uploaded html or svg file would run its script on your site. Use [`dox_storage`](../packages/storage.md) for uploads. It sends such files as downloads. Behind a proxy, the proxy can serve the folder instead. Then the files never reach Dox. ## Fallback route `Route.fallback` answers a `GET` or `HEAD` request that matches no route and no static file. Use it for a custom 404 page, or to send `index.html` of a single page app. ```dart title="lib/routes/web.dart" class WebRouter extends Router { @override void register() { Route.get('/', (WebController c) => c.home); Route.fallback((DoxRequest req) { return response({'message': 'Page not found'}).statusCode(404); }); } } ``` - Other methods still get the normal `404` or `405`. - A `GET` to a path that only has a `POST` route gets the fallback, if a fallback covers that path. If none does, the client gets `405`. - Add the fallback before `startServer()`, like other routes. A fallback added later only works in the isolate that added it. The other isolates do not get it. - The fallback runs its middleware like any route. Add it in a `Router`, so the global middleware (like security headers) runs too. - It follows `Route.group`, the router prefix and `Route.domain`. In a group, it only answers paths under the group prefix. A prefix can have params, like `{tenant}`, and `req.param` gives them. When two fallbacks match, the one with the longest prefix wins. - The handler decides the status. Return a `404` status for a "not found" page, else the client gets `200`. !!! note The built-in `404` and `405` answers do not run the global middleware. Running it there could change answers for apps today. For example, `CsrfMiddleware` would answer `419` to a `POST` to an unknown path, and `SessionMiddleware` would start a session for every bad link. Use `Route.fallback` in a router when your `404` page needs the global middleware. ## Serve storage files `Storage.route(path)` from the [`dox_storage`](../packages/storage.md) package serves the files of a storage disk at `GET /{key*}`. By default only signed URLs work. Other requests get `403`. See [File Storage](../digging-deeper/file-storage.md#serve-local-signed-urls). ```dart Storage.route('files'); Storage.route('public', disk: 'local', signed: false); ``` An html, svg, xml or javascript file is sent as a download (`Content-Disposition: attachment`) with `Content-Security-Policy: sandbox`. So a script in an uploaded file does not run on your site. Every file response also has `X-Content-Type-Options: nosniff`, so the browser does not guess another type. For user uploads, `download: true` sends every file as a download, also images and PDFs: ```dart Storage.route('uploads', download: true); ``` ## Request body for the docs `post`, `put` and `patch` take a `body:` schema. It shows in the [OpenAPI docs](../packages/openapi.md). It does **not** check the request. To check input, use a [DTO](validation.md). ```dart Route.post('/posts', (PostController c) => c.store, body: v.object(>{ 'title': v.string().min(3), })); ``` ## Mount a raw handler `Route.mount(prefix, handler)` sends every request under a path prefix straight to a raw `HttpRequest` handler. It runs before routing and before the body is read. The handler writes and closes the response itself. [Socket.IO](../packages/socketio.md) uses it for `/socket.io/`. ```dart Route.mount('/socket.io/', io.handle); Route.mount('/admin', admin.handle, middleware: true); ``` How the prefix matches: - It matches whole path parts. `/admin` matches `/admin`, `/admin/` and `/admin/users`. It never matches `/administrator` or `/admin-login`. - A trailing `/` does not change the match. `/socket.io/` also matches `/socket.io`. - The query string is not part of the match. - When two prefixes match, the longest one wins. `//` counts as one `/`, so `/admin//secure/x` goes to a mount at `/admin/secure`. - The match uses the raw path. `%2F` stays encoded, so `/admin/secure%2Fx` goes to a mount at `/admin`. To protect a nested path, set `middleware: true` on the outer mount too. !!! warning By default a mount skips all middleware, also the global middleware (security headers, CSRF, rate limit). Set `middleware: true` to run `AppConfig.globalMiddleware` before the handler. The global CORS config never applies to a mount. The handler must send its own CORS headers. With `middleware: true`: - A middleware that gives a response (like `403`) stops the request. The handler does not run. - The body is not read for the middleware, so the handler can still read it. A CSRF token must come in a header. - The handler sends the answer itself. So the part of a `next` middleware that runs after the handler cannot change the answer. - A `terminate` step runs once the answer is sent, like on other routes. - `RequestTimeout` does not run for a mount. The handler already owns the answer. - Cookies that a middleware adds after the handler (the session cookie, `XSRF-TOKEN`) are not sent. Get the CSRF token from a normal route. - An error in the handler gives `500`, like without middleware. --- # Request Source: https://dartondox.dev/latest/the-basic/request/ # Request This page shows how to read input, files, headers and cookies from a request. ## Typed input (recommended) Ask for a [DTO](validation.md) in your controller. Dox reads the body (or the query for `GET`, `HEAD` and `DELETE`), checks it, and gives you a typed object. ```dart Route.put('/users/{id}', (UserController c) => c.update); @Inject() class UserController { Future update( // (1)! int id, // (2)! UpdateUser input, // (3)! ) async { User? user = await User().find(id); user?.name = input.name; await user?.save(); return user; } } ``` 1. The return value is the response. Dox waits for a `Future`. A `String` is sent as text. A `Map` or a model is sent as JSON. 2. A path param. It is `{id}` from the route. `/users/abc` answers `404`, because `abc` is not an `int`. 3. A DTO. Dox reads the body, checks it, and builds the object before this method runs. Bad input gets `422` before your method runs. See [Validation](validation.md). ## Raw input Add a `DoxRequest` parameter to read the raw values. These values are **not** checked. ```dart @Inject() class ApiController { String sayHello(DoxRequest req) { String? name = req.input('name'); return 'Hello $name'; } } ``` | Code | Gives | | --- | --- | | `req.input('name')` | one value from the query string or the body. The body wins when both have the key. | | `req.input('user.name', 'guest')` | a value inside a map or list, with a dot key. The second value is the default, used when the key is not there or is null. | | `req.all()` | all values from the query string and the body, as a `Map` | | `req.only(['name', 'email'])` | only these keys | | `req.except(['password'])` | all values, without these keys | | `req.has('name')` | `true` when the key is there as it is and not empty. It does not read dot keys. | | `req.filled('name')` | `true` when the key is there and not empty. A text of only spaces, an empty list and an empty map count as empty. | | `req.exists('name')` | `true` when the key is there, with any value (also `''` or null) | | `req.missing('name')` | `true` when the key is not there | | `req.boolean('remember')` | `true` for `true`, `1`, `on` and `yes`. Else `false`. | | `req.integer('page', 1)` | the value as an `int`. The default is used when the key is not there or is not a number. | | `req.body` | the body only (JSON, form data) | | `req.query` | the query string only | | `req.add('key', value)`, `req.merge({...})` | add values, for example in a middleware | A dot key reads inside the input. `req.input('items.0.id')` gives the `id` of the first item. A key that is in the input as it is wins. So a body key `'a.b'` is read before the `b` key inside `a`. Dot keys also work in `filled`, `exists`, `missing`, `boolean`, `integer`, `file` and `hasFile`. `has` does not read dot keys: `req.has('user.name')` is `false` for `{"user": {"name": "Ann"}}`. Use `filled` for that. Form and query keys with brackets give lists and maps, like in Laravel: | Input | `req.input(...)` gives | | --- | --- | | `tags[]=a&tags[]=b` | `req.input('tags')` is `['a', 'b']` | | `address[city]=Yangon` | `req.input('address')` is `{'city': 'Yangon'}` | | `items[0][name]=x` | `req.input('items')` is `[{'name': 'x'}]` | | `name=a&name=b` (no brackets) | `req.input('name')` is `'b'`, the last value | ## Route params Ask for a path param as a typed parameter, like `int id` in the example above. The text values are also in `req.param`, like `req.param['id']`. They are URL-decoded: `/users/a%20b` gives `'a b'`. See [Controller: parameters](controller.md#parameters). ## Form data (File) An uploaded file is a `RequestFile`. The request must be `multipart/form-data`. Put the file in a DTO, so Dox checks its type and size: ```dart title="lib/app/dtos/upload_image/upload_image.dto.dart" import 'package:dox_core/dox_core.dart'; part 'upload_image.dto.g.dart'; @Dto() class UploadImage { @Image() @MaxSize('5MB') final RequestFile image; const UploadImage({required this.image}); static const DtoSchema schema = $UploadImageDto; } ``` ```dart title="lib/app/http/controllers/api.controller.dart" @Inject() class ApiController { Future> uploadImage(UploadImage input) async { String uploadedPath = await input.image.store('images'); return {'filename': uploadedPath}; } } ``` `store()` comes from the [`dox_storage`](../packages/storage.md) package. Add it and import `package:dox_storage/dox_storage.dart`. Without a DTO, `req.file('image')` gives the `RequestFile`, or null when the field is not a file. `req.hasFile('image')` is `true` when the field holds a file or a list of files. For a field with many files, use `req.input('images')`. The file is not checked. ### `RequestFile` ```dart RequestFile file = req.input('video'); file.filename; // name from the client file.filetype; // content type, like video/mp4 file.extension; // mp4 file.length; // size in bytes await file.size; // size in KB file.stream(); // read it in chunks await file.store('videos'); // save to storage (needs dox_storage). returns the path await file.moveTo('/data/videos/1.mp4'); // move it. returns the new File await file.bytes; // the whole file in memory. avoid for big files ``` !!! warning "Save the file before you return" A file up to 1 MB stays in memory. A bigger file is written to a temp file while it arrives. The temp file is **deleted when the response is sent**. So call `store`, `moveTo` or `stream` before your controller returns. ### Size limits A body that is too big gets `413 Payload Too Large`. Dox checks `Content-Length` first. It also counts the bytes while it reads the body. ```dart title="lib/config/app.dart" AppConfig( maxBodySize: 10 * 1024 * 1024, // JSON and urlencoded, default 10 MB maxMultipartSize: 100 * 1024 * 1024, // form-data uploads, default 100 MB ); ``` Set the same limit in your proxy, like `client_max_body_size` in nginx. ## Headers ```dart String? token = req.header('X-Token'); Map all = req.headers; // a repeated header is joined with ', ' req.userAgent(); // User-Agent header req.host(); // Host header req.origin(); // Origin header, or null req.referer(); // Referer header, or null req.isJson(); // true for a JSON body req.isFormData(); // true for multipart/form-data req.locale; // first language of Accept-Language, like 'th', or null req.bearerToken(); // the token of 'Authorization: Bearer ', or null ``` ## Path and URL ```dart req.path(); // '/admin/users/5' req.fullUrl(); // 'https://example.com/admin/users/5?tab=info' req.pathIs('admin/*'); // true. `*` matches any text req.routeIs('admin.users.*'); // true when the route name matches ``` `pathIs` cleans the path like the router does. A `/` at the start or the end does not matter, and `//` counts as one `/`. So `'admin/*'` and `'/admin/*'` are the same, and `/admin/` and `/admin//` match `'admin'`, like they reach the `/admin` route. `routeIs` is `false` for a route with no name. Give a route a name with `.name('admin.users.show')`. ## Cookies ```dart String? token = req.cookie('token'); // decrypted value String? raw = req.cookie('theme', decrypt: false); // value as sent ``` See [Response](response.md#cookie) to set a cookie. ## Client IP and scheme ```dart req.ip(); // client IP req.scheme(); // http or https req.isSecure(); // true for https ``` Behind a proxy, `req.ip()` shows the proxy, not the client. Set `trustedProxies` in `AppConfig` to fix it. See [Deployment](../digging-deeper/deployment.md#https-put-a-proxy-in-front). --- # Response Source: https://dartondox.dev/latest/the-basic/response/ # Response This page shows what a controller can return, and how to set the status, headers and cookies. ## Return a value Return the value from your controller. Dox turns it into the response. | You return | The client gets | | --- | --- | | a `String` | the text | | a `Map` or a `List` | JSON | | a model, a list of models, a [serializer](serializer.md) | JSON (from `toJson()`) | | a `DateTime` | the ISO 8601 text | | an `IHttpException`, like `NotFoundHttpException()` | its status code and JSON: `{"code": ..., "message": ...}`, or a `Map` message as it is | | a `File` (from `dart:io`) | the file, streamed | | a `Stream>` | the bytes, streamed | | `StreamFile`, `DownloadableFile` (also from `Storage()` of `dox_storage`) | the file, streamed | | `response(...)` | full control: status, headers, cookies | ```dart // JSON Map hello(DoxRequest req) { return {'message': 'hello world!'}; } // a model Future show(int id) async { return Blog().find(id); } ``` ## Errors Throw or return an exception to answer with an error. | Exception | Status | | --- | --- | | `BadRequestException()` | `400` | | `NotFoundHttpException()` | `404` | | `PayloadTooLargeException()` | `413` | | `UnsupportedMediaTypeException()` | `415` | | `TokenMismatchException()` | `419` | | `ValidationException()` | `422` | | `TooManyRequestsException()` | `429` | | `InternalErrorException()` | `500` | Each one takes `message:`, `errorCode:` and `code:`: ```dart throw NotFoundHttpException(message: 'Blog not found'); ``` The client gets `404` with `{"code":"not_found","message":"Blog not found"}`. `code` is the `errorCode`. A `Map` message is sent as it is. For another status, extend `IHttpException`: ```dart import 'dart:io'; import 'package:dox_core/dox_core.dart'; class ForbiddenException extends IHttpException { ForbiddenException({String message = 'Forbidden'}) { super.code = HttpStatus.forbidden; super.errorCode = 'forbidden'; super.message = message; } } ``` Any other error gets `500`. It is also passed to the [exception handler](errors.md). ## Files and big responses Return a `File` or a `Stream>`. Dox sends it in chunks. It does not load it all into memory. - A `File` sets `Content-Type` (from the file name), `Content-Length` and `Accept-Ranges`. - It answers `Range: bytes=...` with `206 Partial Content`. Video players can seek, and downloads can resume. - `Storage().stream()` and `Storage().download()` on the local disk also stream the file. ```dart import 'dart:io'; import 'package:dox_storage/dox_storage.dart'; // a file from disk Route.get('/video', (DoxRequest req) => File('storage/video.mp4')); // a download from Storage Future download(DoxRequest req) async { // sends Content-Disposition, so the browser saves the file return Storage().download('report.pdf'); } // a stream from Storage Future show(DoxRequest req) async { return Storage().stream('photo.jpg'); } ``` See [File Storage](../digging-deeper/file-storage.md). ## The `response()` helper Use `response(content)` when you need to set the status, headers or cookies. All methods return the response, so you can chain them. ```dart import 'dart:io'; import 'package:dox_core/dox_core.dart'; DoxResponse store(DoxRequest req) { return response({'status': 'created'}) .statusCode(201) .header('X-Request-Id', 'abc') .contentType(ContentType.json); } ``` | Method | Does | | --- | --- | | `statusCode(201)` | sets the status code. Default `200`. | | `header('X-Key', 'value')` | sets one header | | `withHeaders({...})` | adds these headers. A key replaces the header with the same name, in any case. Other headers stay. | | `contentType(ContentType.html)` | sets `Content-Type` (`ContentType` is from `dart:io`) | | `cache(Duration(minutes: 5))` | sets `Cache-Control: max-age=300` | | `cookie(DoxCookie(...))` | sets a cookie | | `content(value)` | changes the body | | `stream(byteStream)` | sends a `Stream>` as the body | ## Redirect ```dart // 302 to a path return redirect('/login'); // another status return redirect('/posts', status: 301); // back to the page the request came from (Referer), or '/' return back(req); ``` See [Session](session.md#redirect-back-with-errors) for flash messages and errors with a redirect. ## Cookie ```dart // set a cookie DoxCookie cookie = DoxCookie('theme', 'dark'); return response('ok').cookie(cookie); // set a cookie with options DoxCookie token = DoxCookie( 'token', 'value', maxAge: Duration(days: 7), // sent as Max-Age in seconds. default 1 hour httpOnly: true, // default true: page scripts can not read it secure: true, // default: Secure only when the request came over https path: '/', sameSite: SameSite.lax, // from dart:io. default: none sent ); return response('ok').cookie(token); // delete a cookie return response('ok').cookie(DoxCookie('token', ''), setExpire: true); ``` To read a cookie, see [Request: cookies](request.md#cookies). !!! info "Encrypt and decrypt" By default, Dox encrypts the cookie value with `APP_KEY`, and decrypts it when you read it. To turn this off, use `DoxCookie(key, value, encrypt: false)` to set it, and `req.cookie(key, decrypt: false)` to read it. ## Change every response To change every response in one place (for example, to add a header), set a `responseHandler` in `AppConfig`. ```dart title="lib/app/http/handler.dart" import 'package:dox_core/dox_core.dart'; class ResponseHandler extends ResponseHandlerInterface { @override DoxResponse handle(DoxResponse res) { return res.header('X-Powered-By', 'Dox'); } } ``` ```dart title="lib/config/app.dart" AppConfig appConfig = AppConfig( responseHandler: ResponseHandler(), // ... ); ``` --- # Views Source: https://dartondox.dev/latest/the-basic/views/ # Views Views are HTML templates. Dox fills them with your data and sends them as the response. The `dox_view` package has a template engine like Edge.js. Templates are `.dox.html` files in `resources/views`. ## Install ```bash dart pub add dox_view ``` Add the provider in `lib/`, so every isolate sets up the engine: ```dart title="lib/providers/view_provider.dart" import 'package:dox_view/dox_view.dart'; class AppViewProvider extends ViewProvider { AppViewProvider() : super(const ViewConfig()); } ``` ## Render a view `view()` renders a template and sends it as HTML. Leave out the `.dox.html` extension. ```dart DoxResponse show(DoxRequest req) { return view(req, 'users/show', { 'user': user, 'posts': posts, }); } ``` This renders `resources/views/users/show.dox.html`: ```html

{{ user.name }}

``` `View.render()` gives the HTML as a string, for example for an email: ```dart String html = View.render('emails/welcome', {'name': 'Ann'}); ``` ## Output `{{ }}` prints a value. It escapes HTML, so a user can not add a ` ``` ## Expressions Inside `{{ }}` and tags, you can use: | What | Example | | --- | --- | | Values | `'text'`, `"text"`, `42`, `1.5`, `true`, `false`, `null` | | Lists and maps | `[1, 2]`, `{ name: 'Ann', 'full name': 'Ann Lee' }` | | Fields | `user.name`, `user?.name`, `items[0]`, `map['key']` | | Math | `+`, `-`, `*`, `/`, `%`, `~/` | | Compare | `==`, `!=`, `<`, `<=`, `>`, `>=` (`===` and `!==` work too) | | Logic | `&&`, `\|\|`, `!`, `a ?? b`, `a ? b : c` | | Functions | `route('users.show', { id: 5 })`, `name.toUpperCase()` | `+` joins text: `{{ 'Hi ' + name }}`. Fields work on maps, and on objects with a `toJson()` method, like Dox models. Lists, maps and text have `length`, `isEmpty` and `isNotEmpty`. Lists also have `first`, `last`, `contains()`, `join()` and `indexOf()`. Text has `toUpperCase()`, `toLowerCase()`, `trim()`, `contains()`, `startsWith()`, `endsWith()`, `split()`, `replaceAll()`, `substring()` and `indexOf()`. Numbers have `toStringAsFixed()`, `round()`, `floor()`, `ceil()` and `abs()`. In `@if` and `?:`, these values are false: `null`, `false`, `0`, `''`, an empty list and an empty map. ## Conditions ```html @if(user.isAdmin) Admin @elseif(user.isEditor) Posts @else

Welcome

@end ``` `@unless(x)` is `@if(!x)`. A tag alone on its line leaves no empty line in the output. A tag can not come right after a letter or a digit, so `me@mail.com` stays text. Put a space before the tag, or use `{{ on ? 'active' : '' }}`. ## Loops ```html
    @each(user in users)
  • {{ user.name }}
  • @else
  • No users yet
  • @end
``` The `@else` part shows when the list is empty. Get the index too, or the key of a map: ```html @each((user, index) in users) {{ index + 1 }}. {{ user.name }} @end @each((value, key) in settings) {{ key }}: {{ value }} @end ``` `$loop` has `index`, `first`, `last` and `length`: ```html @each(tag in tags) {{ tag }}{{ $loop.last ? '' : ', ' }} @end ``` ## Variables `@let` makes a variable. `@assign` changes it. ```html @let(total = 0) @each(item in cart) @assign(total = total + item.price) @end Total: {{ total }} ``` ## Partials `@include` puts another template in this one. It sees the same variables. ```html @include('partials/nav') @includeIf(user, 'partials/account') ``` ## More tags Close a block tag with `@end`. `@else` works in all of them, but not in `@once` and `@verbatim`. Put `@auth`, `@guest`, `@production`, `@once` and `@verbatim` alone on their line. In the middle of text, like `@once upon a time`, they stay text. | Tag | What it does | | --- | --- | | `@isset(user.phone) ... @end` | shows when the value is not `null`. A field of `null` is not an error here. | | `@empty(items) ... @end` | shows when the value is false: `null`, `false`, `0`, `''`, an empty list or map | | `@auth ... @end` | shows when the user is logged in (`req.auth`) | | `@guest ... @end` | shows when the user is not logged in | | `@env('local') ... @end` | shows when `APP_ENV` is this value. A list works too: `@env(['local', 'staging'])`. Case does not matter. | | `@production ... @end` | shows when `APP_ENV` is `production` | | `@once ... @end` | shows only the first time in one render, for example a `
``` This is the format of the Inertia 3 client. In the JSON, `<`, `>` and `&` are written as `\u003c`, `\u003e` and `\u0026`, so a prop can not end the script tag. ### 2. Config ```dart title="lib/config/inertia.dart" import 'package:dox_inertia/dox_inertia.dart'; InertiaConfig inertiaConfig = InertiaConfig( /// the Vite entry files entryPoints: ['resources/js/app.jsx'], /// load the scripts from the Vite dev server. null in production. devServerUrl: 'http://localhost:5173', /// add the React Fast Refresh code in dev mode reactRefresh: true, ); ``` | Option | Default | What it does | | --- | --- | --- | | `rootView` | `resources/views/app.html` | the HTML page of the first visit | | `entryPoints` | `['resources/js/app.js']` | the Vite entry files | | `publicPath` | `public` | the folder of public files | | `buildDirectory` | `build` | the folder in `publicPath` where `vite build` writes its files | | `devServerUrl` | `null` | the url of the Vite dev server, see [Vite](#vite) | | `reactRefresh` | `false` | add the React Fast Refresh code in dev mode | | `version` | hash of the manifest | the asset version, see [Asset version](#asset-version) | Paths are from the project root, where the app runs. ### 3. Provider The provider sets up Inertia in every isolate. Put it in `lib/` so the generated container finds it. ```dart title="lib/providers/inertia_provider.dart" import 'package:dox_inertia/dox_inertia.dart'; import '../config/inertia.dart'; class AppInertiaProvider extends InertiaProvider { AppInertiaProvider() : super(inertiaConfig); } ``` With no provider, Dox uses the defaults of `InertiaConfig`. ### 4. Vite ```js title="vite.config.js" import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' export default defineConfig(({ command }) => ({ plugins: [react()], // public/ holds the Dox public files. The build goes into public/build, // so do not let Vite copy public/ into the build. publicDir: false, // the built files are served from /build/ base: command === 'build' ? '/build/' : '/', server: { origin: 'http://localhost:5173' }, build: { manifest: true, outDir: 'public/build', rollupOptions: { input: 'resources/js/app.jsx' }, }, })) ``` ```jsx title="resources/js/app.jsx" import { createInertiaApp } from '@inertiajs/react' import { createRoot } from 'react-dom/client' createInertiaApp({ resolve: (name) => { const pages = import.meta.glob('./Pages/**/*.jsx', { eager: true }) return pages[`./Pages/${name}.jsx`] }, setup({ el, App, props }) { createRoot(el).render() }, }) ``` ### 5. Serve the built files In production the browser loads `/build/assets/...`. Add a route for them, or let your proxy (nginx, Caddy) serve `public/build`. ```dart title="lib/routes/web.dart" Route.get('/build/{path*}', (String path) { if (path.contains('..')) throw NotFoundHttpException(); // the file names have a hash, so the browser can keep them for a year return response(File('public/build/$path')).cache(const Duration(days: 365)); }); ``` ### 6. Middleware Add three middleware to `globalMiddleware`, in this order: ```dart title="lib/config/app.dart" AppConfig appConfig = AppConfig( globalMiddleware: [ const SessionMiddleware(), const CsrfMiddleware(), HandleInertiaRequests(), ], // ... ); ``` - `SessionMiddleware` gives each request a session. Inertia uses it for validation errors and flash data. - `CsrfMiddleware` stops cross site form posts. It sets the `XSRF-TOKEN` cookie. The Inertia client reads it and sends it back in the `X-XSRF-TOKEN` header. You do not write any code for this. - `HandleInertiaRequests` is your `InertiaMiddleware`, see [Shared props](#shared-props). With no props to share, use `const InertiaMiddleware()`. You can also add them to a router, in the same order, if only some routes are Inertia pages. ## Render a page Give the request, the page name and the props. The page name is the file in `resources/js/Pages`, without the extension. ```dart title="lib/app/http/controllers/user.controller.dart" import 'package:dox_core/dox_core.dart'; import 'package:dox_inertia/dox_inertia.dart'; import 'package:my_app/app/models/user/user.model.dart'; @Inject() class UserController { Future index(DoxRequest req) { return inertia(req, 'Users/Index', { 'title': 'Users', 'users': () => User().query().all(), }); } } ``` - A normal request gets the root view as HTML. - An Inertia request (`X-Inertia: true`) gets the page as JSON, with `X-Inertia: true` and `Vary: X-Inertia`. A prop can be a function, like `users` above. It runs only when the prop is sent. It can give a `Future`. Props become JSON. A model or any object with `toJson()` works. A `DateTime` becomes ISO text. `Inertia.render(req, 'Users/Index', {...})` does the same as `inertia(...)`. ## Shared props Shared props go to every page, like the signed in user or the app name. Extend `InertiaMiddleware` and add it to `globalMiddleware` (see [Middleware](#6-middleware)). ```dart title="lib/app/http/middleware/handle_inertia_requests.dart" import 'package:dox_core/dox_core.dart'; import 'package:dox_inertia/dox_inertia.dart'; class HandleInertiaRequests extends InertiaMiddleware { @override Map share(DoxRequest req) { return { 'appName': 'My app', 'auth': () => req.auth?.toJson(), }; } } ``` A page prop with the same name wins over a shared prop. `share` can be `async`. The page lists the names of the shared props in `sharedProps`. The client keeps them on an instant visit. You can also share from any middleware or controller with `Inertia.share(req, {...})`. For a value that is the same for the whole app, use `Inertia.shareGlobal(key, value)`. Call it once in the `setup()` of your `InertiaProvider`, so it runs in every isolate: ```dart class AppInertiaProvider extends InertiaProvider { AppInertiaProvider() : super(inertiaConfig); @override void setup() { super.setup(); Inertia.shareGlobal('appName', 'Dox'); } } ``` A global value is kept for all users and all requests. Use it only for a constant, or a function that does not read a request. Data of one user, like the signed in user, must go in `InertiaMiddleware.share`. A prop shared for the request wins over a global one. ## Partial reloads The client can ask for some props only: `router.reload({ only: ['users'] })`. Dox reads the headers and sends only those props. Function props that are not sent do not run. Four helpers change when a prop is sent: ```dart return inertia(req, 'Users/Index', { /// only when a partial reload asks for it 'stats': Inertia.optional(() => Stats.load()), /// on every request, also on a partial reload that does not ask for it 'notices': Inertia.always(notices), /// not on the first visit: the page loads it right after 'comments': Inertia.defer(() => Comment().query().all()), 'likes': Inertia.defer(() => Like().query().count(), group: 'side'), /// deferred, and the page adds the new items to the old ones 'feed': Inertia.defer(() => Post().query().all()).merge(), /// the page adds the new items to the old ones (infinite scroll) 'posts': Inertia.merge(posts), }); ``` | Helper | First visit | Partial reload | | --- | --- | --- | | plain value or function | sent | sent when asked | | `Inertia.optional` | not sent | sent when asked | | `Inertia.always` | sent | always sent | | `Inertia.defer` | not sent, listed in `deferredProps` | sent when asked | | `Inertia.merge` | sent, listed in `mergeProps` | sent when asked | Deferred props of the same `group` load in one request. The default group is `default`. `Inertia.lazy` is the same as `Inertia.optional`. A deferred prop with `.merge()` (or `merge: true`) is also listed in `mergeProps` when it is sent. ## Forms and validation errors A form post needs no extra code for errors. The flow is: 1. The page posts the form: `router.post('/users', data)` or `useForm`. 2. The controller validates. The validation fails. 3. Dox flashes the errors and the old input to the session, and redirects back. 4. The page loads again. It gets the errors in the `errors` prop. ```dart Future store(DoxRequest req) async { await req.validateSchema( v.object(>{ 'name': v.string().min(2), 'email': v.string().email(), }), ); // ... save the user return redirect('/users'); } ``` ```jsx const { data, setData, post, errors } = useForm({ name: '', email: '' }) // errors.email holds the message, or is undefined ``` Every page gets `errors`. It is `{}` when there are no errors. It is sent on every request, also on a partial reload. The old input is in `req.session.old('email')`. Password fields are not kept. With `router.post(url, data, { errorBag: 'login' })` or `form.post(url, { errorBag: 'login' })`, the client sends the `X-Inertia-Error-Bag` header. Then the errors are under the bag name: `errors.login.email`. Use it when one page has two forms. You do not need to pass `errors` yourself. A page prop named `errors` still wins. ## Redirects After a form, redirect with `redirect(url)` or `back(req)` from Dox. After `PUT`, `PATCH` or `DELETE`, the browser must follow the redirect with `GET`, not with the same method. So `InertiaMiddleware` turns a `302` into `303` for these Inertia requests. You do not need to do anything. When a controller gives an empty `200` answer to an Inertia request (eg. it returns nothing), `InertiaMiddleware` sends the browser back to the page it came from, like `back(req)`. Only a `Referer` of the same site is used. Any other `Referer` goes to `/`. A request without `X-Inertia` gets the empty answer as it is. ```dart Future update(DoxRequest req, int id) async { // ... save the user return redirect('/users'); // 303 for an Inertia PUT } ``` To go to an outside site, or any page that is not an Inertia page, use `Inertia.location`. An Inertia request gets `409` with `X-Inertia-Location`, and the client loads the url with a full page load. Other requests get `302`. ```dart return Inertia.location(req, 'https://checkout.example.com/pay/1'); ``` ## Flash data Flash data is a short message for the next page, like "User saved". Use `Inertia.flash`. The next page gets it in `page.flash`, once. ```dart Future store(DoxRequest req) async { // ... save the user Inertia.flash(req, 'message', 'User saved'); return redirect('/users'); } ``` ```jsx import { usePage } from '@inertiajs/react' const { flash } = usePage() // flash.message is 'User saved' ``` The client also fires a `flash` event, and calls the `onFlash` callback of the visit. `Inertia.flash` needs `SessionMiddleware`. ## Vite In dev mode, `@vite` loads `@vite/client` and the entry files from the Vite dev server. With `reactRefresh: true` it adds the React Fast Refresh code first. Dox is in dev mode when: - `devServerUrl` is set, or - the file `public/hot` exists. Its text is the dev server url. Some Vite plugins write this file while `vite` runs. Dox ignores this file only when `APP_ENV` is exactly `production`, and prints a warning. Other values, like `staging` or an empty value, still use it. In production (no dev server), Dox reads `public/build/.vite/manifest.json`. `@vite` gives a `` tag for the css of each entry and of the chunks it imports, and a `