Routing
Routes are the front door of a Maat application. They connect an HTTP method and URI to the code that should handle the request:
Route.get('/posts/{post}', (Request request, String post) { return {'post': post};});This route answers GET /posts/42, captures 42 as post, and normalises the
returned map into JSON. Most applications keep routes short and move growing
request logic into a controller.
Basic Routing
The simplest route accepts a URI and a handler:
Route.get('/', (Request request) => 'Hello world');Maat provides a method for each HTTP verb:
Route.get('/posts', index);Route.post('/posts', store);Route.put('/posts/{id}', update);Route.patch('/posts/{id}', update);Route.delete('/posts/{id}', destroy);Route.options('/posts', preflight);Route.any registers a route for every verb, and Route.match for a chosen few:
Route.any('/webhook', handle);Route.match(['GET', 'POST'], '/search', search);Routes are declared in routes/api.dart and routes/web.dart, which the RouteServiceProvider loads for you. HEAD is handled automatically wherever you register a GET.
Route Handlers
A handler receives the Request and returns whatever it likes. The return value is normalised into a Response:
Route.get('/health', (Request request) => {'status': 'ok'}); // JSONRoute.get('/welcome', (Request request) => '<h1>Welcome</h1>'); // HTMLRoute.get('/made', (Request request) => Response.json({}, status: 201));Route.get('/gone', (Request request) => Response.noContent());Returning null produces a 204 No Content. See Responses for the full normalisation rules.
Handlers may be async, and may be a method on a controller:
class PostController extends Controller { Future<Response> index(Request request) async => Response.json(await fetchAll());}
final posts = PostController();Route.get('/posts', posts.index);See Controllers for controller middleware, single-action controllers and Route.controller() groups.
Route Parameters
Wrap a segment in braces to capture it. Captured parameters are passed to the handler as positional String arguments, after the request:
Route.get('/posts/{id}', (Request request, String id) => {'id': id});Route.get('/posts/{post}/comments/{comment}', (Request request, String post, String comment) => {'post': post, 'comment': comment});You may also read them off the request, which is convenient when a handler takes only the request:
Route.get('/posts/{id}', (Request request) => {'id': request.param('id')});Maat resolves handler arity with type checks rather than reflection, so a handler must accept the request followed by zero or more positional
Stringparameters. A signature it cannot match is rejected when the route runs.
Constraining Parameters
where restricts a parameter to a regular expression. A URI that does not match simply does not match the route:
Route.get('/posts/{id}', show).where('id', r'\d+');Route.get('/users/{name}', show).where('name', r'[a-z]+');Constraints are fixed application code, not request data. Do not build a route regular expression from user input: complex untrusted patterns can make route matching unexpectedly expensive.
Optional Parameters
Suffix a parameter with ? to make it optional:
Route.get('/posts/{page?}', (Request request) => request.param('page') ?? '1');Named Routes
Name a route to generate URLs for it later:
Route.get('/posts/{id}', show).name('posts.show');Then:
route('posts.show', {'id': '7'}); // /posts/7Extra parameters that do not appear in the URI are appended as a query string. Names must be unique — registering the same name twice throws immediately, at boot, rather than silently shadowing.
Route Groups
Groups apply attributes to every route inside them:
Route.group(() { Route.get('/posts', index).name('posts.index'); Route.post('/posts', store).name('posts.store');}, prefix: '/api', middleware: ['throttle:60,1'], name: 'api.');That registers /api/posts under the names api.posts.index and api.posts.store, with the throttle middleware applied to both.
Groups nest, and the attributes accumulate:
Route.group(() { Route.group(() { Route.get('/users', index); // /api/admin/users }, prefix: '/admin', middleware: ['can:admin']);}, prefix: '/api', middleware: ['auth']);The Fluent Builder
For a single attribute, the fluent form reads better:
Route.prefix('/api').group(() { Route.get('/posts', index);});
Route.middleware(['auth']).name('admin.').group(() { Route.get('/dashboard', dashboard);});Route.prefix, Route.middleware and Route.name each return a builder you may chain.
Resource Routes
A resource controller extends ResourceController and implements the actions you need:
class PostController extends ResourceController { @override Future<Object?> index(Request request) async => fetchAll();
@override Future<Object?> show(Request request, String id) async => fetchOne(id);}Register all five routes at once:
Route.resource('posts', PostController());| Verb | URI | Action | Route Name |
|---|---|---|---|
| GET | /posts |
index | posts.index |
| POST | /posts |
store | posts.store |
| GET | /posts/{id} |
show | posts.show |
| PUT, PATCH | /posts/{id} |
update | posts.update |
| DELETE | /posts/{id} |
destroy | posts.destroy |
Any action you do not override returns a 404. You may register a subset explicitly:
Route.resource('posts', PostController(), only: ['index', 'show']);Route.resource('posts', PostController(), except: ['destroy']);Generate a resource controller with the stubs already in place:
maat make:controller PostController --apiRoute-Model Binding
A parameter can arrive at the handler as a loaded model instead of a string.
Register the binding once, add the bindings middleware to the route, and read
the model with bound:
// In a service provider's boot():ModelBinding.bind('post', Post.def);
Route.get('/posts/{post}', (Request request) { return PostResource(request.bound<Post>('post'));}).middleware(['bindings']);A row that does not exist aborts with a 404 before the handler runs, which
deletes the find-or-404 boilerplate from every controller.
A bound model arrives with no relations loaded. The binder runs one query
for the row itself and nothing else, because eager-loading by default would run
queries every route pays for and few need. ?include= therefore does nothing on
a bound model — it is a query-builder feature, and the binder’s query is already
finished. Load what the response needs in the handler:
Route.get('/posts/{post}', (Request request) async { final post = await request.bound<Post>('post').load(['comments']); return PostResource(post);}).middleware(['bindings']);load returns the loaded instance; models are immutable, so the return value is
the one to use.
By default the parameter is matched against the model’s primary key. Pass key
to bind by another column — a slug, for example:
ModelBinding.bind('post', Post.def, key: 'slug'); // /posts/hello-worldModelBinding and the bindings alias come from package:seshat_maat; the
alias is registered by DatabaseServiceProvider, or you can add
ModelBinding.middleware() to a route or group directly.
Route middleware, never global. Global middleware runs before the router matches, so no route parameters exist yet and nothing would resolve. Put
bindingson the route or group.
Why not
fn(Post $post)? Laravel resolves the type hint on the handler at runtime. Maat detects handler arity with type checks rather than reflection, and there is no way to testhandler is Function(Request, Post)without knowingPostat the router level. Laravel has an explicitRoute::model()for the same purpose; this ports its mechanism, not its syntax.
bound throws rather than returning null when the parameter was never bound.
The error names the parameter, lists what was bound on the request, and lists
the route’s parameters, so a typo and missing middleware are easy to tell apart.
Fallback Routes
A fallback handles any request that matched no route:
Route.fallback((Request request) => Response.json({'message': 'Not Found'}, status: 404));Without one, an unmatched URI produces a 404, and a URI that matches a route registered under a different verb produces a 405 with an allow header listing the verbs that are permitted. Method mismatches are answered before the fallback runs, so a wrong-verb request never silently falls through to your catch-all.
Inspecting Routes
maat route:listMethod URI Name Middleware--------- --------------- ------------- ----------GET /api/healthGET /api/posts posts.index throttle:60,1POST /api/posts posts.store throttle:60,1GET /api/posts/{id} posts.show