Skip to content

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'}); // JSON
Route.get('/welcome', (Request request) => '<h1>Welcome</h1>'); // HTML
Route.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 String parameters. 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/7

Extra 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:

Terminal window
maat make:controller PostController --api

Route-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-world

ModelBinding 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 bindings on 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 test handler is Function(Request, Post) without knowing Post at the router level. Laravel has an explicit Route::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

Terminal window
maat route:list
Method URI Name Middleware
--------- --------------- ------------- ----------
GET /api/health
GET /api/posts posts.index throttle:60,1
POST /api/posts posts.store throttle:60,1
GET /api/posts/{id} posts.show