Skip to content

Building JSON APIs

An API write follows the same short path as the rest of Maat: validate the request, perform the action, then shape the response.

class PostController extends ResourceController {
@override
Future<Object?> store(Request request) async {
final data = await request.validate({
'title': 'required|string|max:120',
'body': 'required|string',
});
final post = await Post.query().create(data);
return Response.json(PostResource(post).resolve(request), status: 201);
}
}
Route.resource('posts', PostController());

Nothing separates an API from the rest of a Maat application: the same router, the same validation, the same models. What changes is the shape of what a handler returns and how errors come back.

Resource Routes

Route.resource('posts', PostController());

registers the five actions Laravel does:

Verb URI Action
GET/HEAD /posts index
POST /posts store
GET/HEAD /posts/{id} show
PUT/PATCH /posts/{id} update
DELETE /posts/{id} destroy

Narrow it with only: or except:. The controller extends ResourceController and overrides what it exposes; the rest answer 404.

The Resource Controller

Override only the actions the endpoint exposes. An action left on the base ResourceController returns a 404, and only or except can prevent its route from being registered at all:

class PostController extends ResourceController {
@override
Future<Object?> index(Request request) async {
final posts = await Post.query().paginate(page: 1, perPage: 15);
return resourceCollection(posts, PostResource.new, request);
}
@override
Future<Object?> show(Request request, String id) async {
final post = await Post.query().findOrFail(id);
return PostResource(post).resolve(request);
}
}
Route.resource('posts', PostController(), only: ['index', 'show']);

Group resources under a prefix in a service provider:

Route.prefix('api').name('api.').group(apiRoutes);

Prefix the names as well as the paths. Route.resource names its routes after the resource, so an API posts.index and a web posts.index collide — and the router throws rather than letting one silently replace the other.

Shaping the Response

A JsonResource decides what a model looks like on the wire, so the model keeps no presentation concerns:

class PostResource extends JsonResource<Post> {
PostResource(super.resource);
@override
Map<String, Object?> toJson(Request request) => {
'id': resource.id,
'title': resource.title,
'comments': resourceCollection(
whenLoaded('comments'),
CommentResource.new,
request,
),
};
}

Return PostResource(post).resolve(request) from a handler. The payload is wrapped in data — override wrap to turn that off.

  • when(condition, () => value) omits a key entirely rather than setting it to null. Absent and null say different things to a client.
  • whenLoaded('comments') omits the key unless the relation was eager loaded, which is what stops a collection endpoint from issuing a query per row.
  • resourceCollection(items, PostResource.new, request) shapes many. It takes a constructor tear-off rather than a static, because Dart does not inherit statics: PostResource.collection could never resolve.

Pagination

Pass a Paginator to resourceCollection and the response gains Laravel’s links and meta alongside data:

final page = await Post.query().paginate(page: 1, perPage: 15);
return resourceCollection(page, PostResource.new, request);
{
"data": [...],
"links": { "first": "...", "last": "...", "prev": null, "next": "...?page=2" },
"meta": { "current_page": 1, "per_page": 15, "total": 42, "last_page": 3 }
}

Errors

The exception handler renders JSON when the client asks for it — an Accept: application/json header or any path beginning with /api. A validation failure becomes 422 with the same errors map the HTML flow gets; a NotFoundHttpException becomes 404, and so on.

{ "message": "The given data was invalid.", "errors": { "title": ["..."] } }

A non-API client that sends no Accept header gets the HTML error page, exactly as in Laravel. Send the header when the endpoint does not use the /api prefix.

Throw ValidationException({'field': ['message']}) yourself for anything the rule strings cannot express — a foreign key that does not exist, a parent that would point at itself. Without it those surface as a database error and a 500, which tells the client nothing about which field to fix.

Query Parameters

Everything in a query string arrives as text and much of it ends up in SQL. Parse each one into the type the column holds and reject what does not fit:

int _int(String raw, String field) =>
int.tryParse(raw) ??
(throw ValidationException({field: ['The $field field must be an integer.']}));

Bindings mean a stray string cannot rewrite the query, but a 500 from a type error is still a worse answer than a 422 naming the field. Never let a parameter choose a column or a sort direction without checking it against a list you control.

Protecting Writes

Declare the middleware on the controller and it travels with the actions, wherever they are registered:

class PostController extends ResourceController {
@override
List<Object> middleware() => [
ControllerMiddleware('auth.token', only: [store, update, destroy]),
];
}

ControllerMiddleware names actions by tear-off rather than by string, so a renamed method is a compile error instead of a silently unprotected route.

The examples/todo application implements auth.token as a single shared secret. Two things matter more than the mechanism:

  • Fail closed. An unset token answers 503, never “no token required”.
  • Compare in constant time. == on a String returns at the first differing character, which lets an attacker who can time responses recover a secret one character at a time.

Configuration Files Are Getters

Map<String, dynamic> get app => {'api_token': env('API_TOKEN', '')};

Not final. A top-level final is initialised once per isolate, so its env(...) calls freeze at whatever the first application to boot saw. Every test that builds a second application with a different environment would then be handed the first one’s configuration, with nothing to show for it.