Skip to content

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 whenLoaded on 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:

Terminal window
maat make:resource PostResource
maat make:resource AuthorResource --model=User

make: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() for created_at and updated_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:

@override
Map<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'). with is a Dart keyword, so it becomes with_(['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 writes PostResource::collection($posts). Dart does not inherit statics, so a collection declared on JsonResource would not resolve through PostResource. 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_page cap is not optional. ?per_page=1000000 is 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 with resolvePerPage(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); // uncapped
await Post.def.query().paginateRequest(
request,
perPage: resolvePerPage(request, max: 250),
); // capped at 250

Use paginatedResponse(page, request) directly when the items are already plain maps and no resource is involved.

Named paginateRequest, not paginate: Dart resolves instance members before extension members, so a paginate(Request) extension would be unreachable behind the query builder’s own paginate({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 422 naming what is allowed, never a silent drop. A drop reaches the client as missing data with a 200, and they cannot tell a typo from an empty relation. The body is {"message": ...} with no errors key — unlike a validation 422, 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, because wrap is 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.