API Resources
A resource is the translation layer between a model and the JSON your API returns. It keeps presentation out of the model, so the same Post can be rendered one way for a list and another way for an owner.
Defining Resource Attributes
class PostResource extends JsonResource<Post> { PostResource(super.resource);
@override Map<String, Object?> toJson(Request request) => { 'id': resource.id, 'title': resource.title, 'published_at': resource.publishedAt?.toIso8601String(), };}Return one from a route and the framework renders it:
Route.get('/posts/{post}', (Request request) { return PostResource(request.bound<Post>('post'));}).middleware(['bindings']);A route-model-bound instance arrives with no relations loaded, so
whenLoadedon it is always absent and?include=does nothing — the binder’s query has already run. Await.load(['comments'])on the bound model in the handler before building the resource. See Route-Model Binding.
{ "data": { "id": 7, "title": "Hello", "published_at": "2026-09-02T09:00:00.000Z" }}The data envelope is Laravel’s default and Maat’s. resolve() is called for you by the kernel — it is the only place both the resource and its request are in scope.
Generate one with:
maat make:resource PostResourcemaat make:resource AuthorResource --model=Usermake:model Post -r writes the model and its resource together, with a line per declared field.
make:resource alone assumes the model has id, createdAt and updatedAt — the same shape make:model -r generates. Point it at a hand-written model missing one of those and the resource will not compile; the missing-model warning does not catch this, because the model file does exist.
Maat’s response encoder understands
DateTime, but serialising dates in the resource keeps the wire format visible and stable. The generated stub uses?.toIso8601String()forcreated_atandupdated_at.
Conditional Attributes
when includes a key only when a condition holds, and removes it otherwise. Absent and null mean different things to a client, so a hidden field does not become "email": null:
@overrideMap<String, Object?> toJson(Request request) => { 'id': resource.id, 'email': when(request.header('x-role') == 'admin', () => resource.email),};The callback is lazy — it never runs when the condition is false, so when(isAdmin, () => expensiveLookup()) costs nothing for everyone else.
Relationships
whenLoaded includes a relation only when it was eager-loaded. It is the guard against the N+1 that a bare resource.posts() would cause on a collection:
class UserResource extends JsonResource<User> { UserResource(super.resource);
@override Map<String, Object?> toJson(Request request) => { 'name': resource.name, 'posts': resourceCollection(whenLoaded('posts'), PostResource.new, request), };}
final users = await User.def.with_(['posts']).get();Laravel spells this
->with('posts').withis a Dart keyword, so it becomeswith_(['posts'])— a list, since Dart has no variadic parameters. See Coming From Laravel for the rest of the spellings a Laravel habit will reach for and get wrong.
When the relation was not loaded, whenLoaded returns a missing marker, resourceCollection passes it straight through, and the posts key never appears. It is deliberately not an empty list: an empty list tells the client the user has no posts, which is a different and false claim.
Nested resources are inlined unwrapped — a resource inside a resource contributes its fields, not a second data envelope.
Resource Collections
resourceCollection shapes many models at once:
Route.get('/posts', (Request request) async { final posts = await Post.def.query().orderBy('id').get();
return resourceCollection(posts, PostResource.new, request);});{ "data": [ { "id": 1, ... }, { "id": 2, ... } ] }It takes three arguments: the items, a constructor tear-off (PostResource.new), and the request.
Why a function and not
PostResource.collection(...)? Laravel writesPostResource::collection($posts). Dart does not inherit statics, so acollectiondeclared onJsonResourcewould not resolve throughPostResource. The tear-off is the port of that mechanism, not of its syntax.
The request is required rather than optional because every item is shaped by toJson(request). A fabricated request would render each one against GET http://localhost/ — headers gone, path wrong, when(isAdmin, ...) silently false. You are already inside toJson(Request request) and have one to hand.
items may be an Iterable, a Paginator, the missing marker from whenLoaded, or null (a nullable relation stays null).
Pagination
paginateRequest reads ?page= and ?per_page= off the request:
Route.get('/posts', (Request request) async { final page = await Post.def.query().orderBy('id').paginateRequest(request);
return resourceCollection(page, PostResource.new, request);});Handing a Paginator to resourceCollection produces Laravel’s full envelope:
{ "data": [ ... ], "links": { "first": "...", "last": "...", "prev": null, "next": "/posts?page=2" }, "meta": { "current_page": 1, "per_page": 15, "total": 134, "last_page": 9, "from": 1, "to": 15 }}| Parameter | Read by | Default | Behaviour |
|---|---|---|---|
?page= |
resolvePage |
1 |
Anything unparseable or below 1 becomes 1. |
?per_page= |
resolvePerPage |
15 |
Capped at 100. Anything unparseable or below 1 falls back to 15. |
The
per_pagecap is not optional.?per_page=1000000is a trivial denial-of-service against any paginated endpoint. Laravel leaves this to the developer and it is routinely forgotten, so Maat clamps it by default. Change the ceiling withresolvePerPage(request, max: 250)and pass the result yourself.
An explicit perPage argument overrides ?per_page= and bypasses the cap — deliberately, because the cap defends against a client-supplied page size and this one is chosen in server code:
await Post.def.query().paginateRequest(request, perPage: 500); // uncappedawait Post.def.query().paginateRequest( request, perPage: resolvePerPage(request, max: 250),); // capped at 250Use paginatedResponse(page, request) directly when the items are already plain maps and no resource is involved.
Named
paginateRequest, notpaginate: Dart resolves instance members before extension members, so apaginate(Request)extension would be unreachable behind the query builder’s ownpaginate({page, perPage}). The ORM stays HTTP-unaware, so the extension yields the name.
Eager Loading From the Request
includes lets a client ask for relations with ?include=posts,posts.comments — but only the ones you allow:
Route.get('/users', (Request request) async { final users = await User.def .query() .includes(request, allow: ['posts', 'posts.comments']) .get();
return resourceCollection(users, UserResource.new, request);});The allowlist is mandatory; there is no allow-everything mode. An unrestricted ?include= is two holes at once: ?include=user.paymentMethods walks a relation graph the endpoint never meant to expose, and nested includes multiply queries until a client can walk the server over.
| Rule | Behaviour |
|---|---|
Not in allow |
422, naming the permitted values. |
Deeper than maxDepth (2 by default) |
422. Depth counts levels, not dots: posts is 1, posts.comments is 2. |
| Any value rejected | Nothing is applied to the builder — no half-built query. |
| Matching | Exact and case-sensitive. |
Allowlisting posts.comments also permits posts, because the nested load returns the parent anyway. The implication runs one way only: allowing posts does not permit posts.comments.
A rejected include is a
422naming what is allowed, never a silent drop. A drop reaches the client as missing data with a200, and they cannot tell a typo from an empty relation. The body is{"message": ...}with noerrorskey — unlike a validation422, which is a different exception.
Two developer mistakes throw StateError rather than answering 422, because no client can provoke or fix them: calling includes on a builder with no ModelDefinition (a bare DB.table(...) cannot eager-load at all), and allowlisting a relation the model does not declare.
Extra Metadata
additional merges keys beside data:
return PostResource(post).additional({'meta': {'version': 2}});{ "meta": { "version": 2 }, "data": { ... } }The resource’s own payload always wins, so additional({'data': ...}) cannot clobber the body.
On an item inside a collection there is no envelope for the keys to sit beside, so they are merged into the item itself — still behind the body, and identically whether the item resource wraps or not:
resourceCollection(posts, (p) => PostResource(p).additional({'note': 'hi'}), request);{ "data": [ { "note": "hi", "id": 7, "title": "Hello" } ] }Laravel differs here: it applies
additional()in the response object, so a per-item call is ignored. Maat keeps the keys rather than dropping them, becausewrapis meant to control the envelope and nothing else. Response-level metadata still belongs on the resource the route returns, not on each row.
Disabling the Wrapper
class PostResource extends JsonResource<Post> { PostResource(super.resource);
@override bool get wrap => false;
@override Map<String, Object?> toJson(Request request) => {'id': resource.id};}{ "id": 7 }Reference
| Member | Description |
|---|---|
resource |
The model being shaped. |
toJson(request) |
The payload. Implement this. |
when(condition, () => value) |
Present only when condition; the callback is lazy. |
whenLoaded('posts') |
Present only when the relation was eager-loaded. |
additional({...}) |
Extra top-level keys beside data. Returns the resource. |
wrap |
Override to false to drop the data envelope. |
resolve(request) |
The final map, with missing values stripped. Called for you. |
JsonResource.isMissing(value) |
Whether a value is the missing marker. |
| Function | Description |
|---|---|
resourceCollection(items, Res.new, request) |
Shapes an Iterable, a Paginator, null, or a missing marker. |
paginatedResponse(page, request, {item}) |
The data/links/meta envelope for a Paginator. |
resolvePage(request) |
?page=, defaulting to 1. |
resolvePerPage(request, {fallback, max}) |
?per_page=, clamped. |
Extension on QueryBuilder<T> |
Description |
|---|---|
paginateRequest(request, {perPage}) |
Paginate from the request’s query string. |
includes(request, {allow, maxDepth}) |
Eager load ?include=, restricted to allow. |
See also: Seshat for models and relations, Routing for turning {post} into a loaded model, and Testing for the factories that build the models these resources render.