mundoprogra / json-api
JSON:API 1.1 request validation and responses for Laravel 10 to 13.
Fund package maintenance!
Requires
- php: ^8.1
- illuminate/auth: ^10.0||^11.0||^12.0||^13.0
- illuminate/contracts: ^10.0||^11.0||^12.0||^13.0
- illuminate/database: ^10.0||^11.0||^12.0||^13.0
- illuminate/http: ^10.0||^11.0||^12.0||^13.0
- illuminate/pagination: ^10.0||^11.0||^12.0||^13.0
- illuminate/routing: ^10.0||^11.0||^12.0||^13.0
- illuminate/support: ^10.0||^11.0||^12.0||^13.0
- illuminate/translation: ^10.0||^11.0||^12.0||^13.0
- illuminate/validation: ^10.0||^11.0||^12.0||^13.0
Requires (Dev)
- larastan/larastan: ^2.9||^3.0
- laravel/pint: ^1.14
- opis/json-schema: ^2.3
- orchestra/testbench: ^8.0||^9.0||^10.0||^11.0
- phpunit/phpunit: ^10.5||^11.0||^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is not auto-updated.
Last update: 2026-10-28 23:21:13 UTC
README
Español · English
Español
Paquete para Laravel que convierte una API en un servicio JSON:API 1.1 de punta a punta: acepta y valida las peticiones en el estándar y responde en el estándar, tanto en éxito como en error.
Estado: versión
0.1.0, en validación. La API descrita aquí está completa, cubierta por pruebas en Laravel 10 a 13 y probada en una aplicación real. En0.xla API puede cambiar entre versiones menores (con^0.1Composer solo instala0.1.x). El detalle de cada cambio está en el CHANGELOG.
Qué hace
- Responde en JSON:API: recursos, colecciones paginadas, relaciones,
included,linksymeta, y todos los errores (incluidos los de validación,401,404,405y500) como documentos de errores consource.pointerosource.parameter. - Valida lo que llega: las cabeceras (
AcceptyContent-Type), los parámetros de consulta (include,fields,sort,filter,page) y el documento del cuerpo, con el puntero exacto del miembro que falla. - Aplica la consulta a Eloquent con listas blancas que escribes tú: filtros, orden, paginación y carga anticipada de relaciones.
- No registra rutas ni controladores: tú decides las URL y la lógica; el paquete se ocupa del formato.
- Se activa solo al instalarlo, sin tocar
bootstrap/app.php,KernelniHandler.
Requisitos
- PHP 8.1 o superior
- Laravel 10, 11, 12 o 13
Instalación
composer require mundoprogra/json-api
El paquete se descubre solo. Para cambiar la configuración o los textos de los errores:
php artisan vendor:publish --tag="json-api-config" php artisan vendor:publish --tag="json-api-lang"
Primeros pasos
Por defecto el paquete actúa sobre las rutas que empiezan con api (mode = prefix, path_prefix = api). Todo lo demás responde como siempre. Para que toda la aplicación responda en JSON:API, usa JSON_API_MODE=global en el .env.
1. Un recurso (qué se muestra de un modelo):
use Illuminate\Http\Request; use MundoProgra\JsonApi\Resources\Relationship; use MundoProgra\JsonApi\Resources\ResourceObject; class ArticleResource extends ResourceObject { // El nombre corto de la ruta que muestra un artículo: de ahí sale su `links.self`. protected function jsonApiRoute(): ?string { return 'articles.show'; } // Los nombres se escriben en snake_case y salen en el estilo configurado (`createdAt` por defecto). protected function jsonApiAttributes(Request $request): array { return [ 'title' => $this->title, 'status' => $this->status, 'created_at' => $this->created_at?->toIso8601String(), ]; } // Solo salen las relaciones que están cargadas: el paquete nunca dispara una consulta para completar un documento. protected function jsonApiRelationships(Request $request): array { return [ 'author' => $this->whenLoaded('author', fn() => Relationship::one($this->author, AuthorResource::class)), 'tags' => $this->whenLoaded('tags', fn() => Relationship::many($this->tags, TagResource::class)), ]; } }
El type de un recurso es la tabla del modelo y su id es la clave de ruta, siempre como texto. Se cambian sobrescribiendo type() e id().
2. Un controlador que aplica la consulta del cliente con listas blancas:
class ArticleController { public function index() { return ArticleResource::collection( Article::query() ->jsonApiFilter(exact: ['status', 'author' => 'author_id'], partial: ['title'], scopes: ['published']) ->jsonApiSort(['id', 'title', 'created_at'], default: '-id') ->jsonApiInclude(['author', 'tags', 'comments.author']) ->jsonApiPaginate() ); } public function show(string $article) { return ArticleResource::make( Article::query()->jsonApiInclude(['author', 'tags'])->findOrFail($article) ); } }
Con eso, GET /api/articles?filter[status]=published&sort=-createdAt&include=author&fields[articles]=title,author&page[size]=5 ya funciona de punta a punta: filtra, ordena, pagina, carga el autor sin consultas de más y devuelve el documento con data, included, links y meta. Cada filtro, orden o include que no esté en tu lista responde 400 con el parámetro que falla.
Recursos, colecciones y relaciones
| Quieres | Escribes |
|---|---|
| Un recurso | ArticleResource::make($article) |
| Una colección | ArticleResource::collection($articles) (un arreglo, una colección o un paginador de longitud conocida) |
| Una colección paginada | ArticleResource::collection(Article::query()->jsonApiPaginate()) |
links o meta propios |
jsonApiLinks($request) y jsonApiMeta($request) en el recurso, o ->additional([...]) |
| Una relación a uno o a muchos | Relationship::one($modelo, Clase::class) / Relationship::many($modelos, Clase::class) |
| Una relación sin cargarla | Relationship::unloaded()->links(...$this->relationshipLinks($request, 'author')) |
| El recurso relacionado vacío | AuthorResource::make(null), que responde data: null |
- Una colección vacía es
data: []. La paginación sumalinks(first,last,prev,next) ymeta.page(currentPage,perPage,lastPage,total), que conservansort,filter,includeyfieldstal como los mandó el cliente. - Un paginador simple o de cursor no se acepta (no sabe cuántas páginas hay) y lanza un error claro.
relationshipLinks($request, 'author')arma{self}/relationships/authory{self}/authora partir dellinks.selfdel propio recurso. Es opcional.included: sale solo lo que el cliente pidió coninclude, de relaciones ya cargadas y que el recurso muestra. Cada recurso aparece una sola vez (portypeeid) y nunca repite a los dedata. Lo que unfields[tipo]oculta tampoco se incluye, así el documento cumple el full linkage de la spec. Para cargar las relaciones sin N+1, usajsonApiInclude([...]).- Sparse fieldsets:
fields[articles]=titlerecorta los atributos y las relaciones de ese tipo;type,id,linksymetase quedan.
Endpoints de relación
Una Relationship devuelta desde un controlador es la respuesta de un endpoint de relación (GET /articles/1/relationships/author): {jsonapi, links, data, meta} con solo el linkage (type e id).
public function author(string $article) { $model = Article::query()->with('author')->findOrFail($article); return Relationship::one($model->author, AuthorResource::class)->links( self: route('articles.relationships.author', [$model]), related: route('articles.author', [$model]), ); }
Para escribir una relación, lee el linkage con RelationshipDocument (el paquete ya validó su forma). Lo que depende de tu modelo lo revisas tú, con estos códigos:
use MundoProgra\JsonApi\Exceptions\JsonApiException; use MundoProgra\JsonApi\Http\Requests\RelationshipDocument; public function updateAuthor(Request $request, string $article) { $document = RelationshipDocument::of($request); // data() es null, un identificador o una lista; isList() dice cuál if ($document->isList()) { // una lista donde va un identificador throw JsonApiException::make(status: 422, detail: 'A to-one relationship takes one identifier or null.', pointer: '/data'); } $identifier = $document->data(); // ['type' => 'authors', 'id' => '3'] o null if ($identifier !== null && $identifier['type'] !== 'authors') { throw JsonApiException::make(status: 409, pointer: '/data/type'); // el tipo no es el de la relación } $author = $identifier === null ? null : Author::find($identifier['id']) ?? throw JsonApiException::make(status: 404, pointer: '/data'); // el recurso no existe // ... asociar, guardar y responder con la relación resultante (`200`) }
PATCH reemplaza, POST agrega y DELETE quita en una relación a muchos. Revisa todo antes de escribir, para que un error no deje cambios a medias. El ejemplo completo está en tests/Fixtures/ReferenceApi.
Parámetros de consulta
| Macro del constructor de Eloquent | Qué hace |
|---|---|
jsonApiPaginate(?int $perPage = null, ?int $maxPerPage = null) |
Trae la página que pidió el cliente (page[number], page[size]) y devuelve el paginador de Laravel. Una página fuera de rango es una lista vacía. |
jsonApiSort(array $allowed, ?string $default = null) |
Aplica sort (- para descendente) con una lista blanca. Agrega la clave primaria para que las páginas sean estables. Acepta ['author' => 'author_id'] para nombrar de otra forma una columna. |
jsonApiFilter(exact: [], partial: [], scopes: []) |
exact: igualdad (con comas, un IN). partial: LIKE %valor% con %, _ escapados. scopes: llama a un scope del modelo. Un nombre fuera de las listas es 400. |
jsonApiInclude(array $allowed) |
Carga por anticipado las relaciones de include (['author', 'comments.author']; pedir una ruta permite sus prefijos). Una ruta fuera de la lista es 400. |
Las listas se escriben en snake_case (las columnas y relaciones de tu modelo); el cliente las manda en el estilo configurado.
Tamaño de página: sale del cliente (page[size]), si no del argumento jsonApiPaginate(perPage: 30), si no de las opciones del middleware (->middleware('json-api.options:per_page=25,max_per_page=50')) y, si no, de la configuración; siempre acotado por el máximo.
$request->jsonApiQuery() entrega la consulta ya analizada (includes(), fields(), sort(), filter(), page()).
Leer el documento de entrada
El paquete valida el cuerpo de todo POST y PATCH JSON antes de que llegue a tu controlador, y de un endpoint de relación también el DELETE. Para leerlo, la petición tiene estos métodos (los nombres de atributos y relaciones van en snake_case):
| Método | Devuelve |
|---|---|
$request->isJsonApi() |
Si el paquete trata la petición como de JSON:API |
$request->jsonApiType() / jsonApiId() |
El type y el id del documento (id es null si no vino) |
$request->jsonApiAttributes() |
Los atributos, con los nombres en snake_case |
$request->hasJsonApiRelationship('author') |
Si el cliente mandó esa relación (aunque sea data: null) |
$request->jsonApiRelationshipId('author') |
El id de una relación a uno, o null |
$request->jsonApiRelationshipIds('tags') |
Los id de una relación a muchos, en orden |
Sin el nombre, jsonApiRelationshipId() y jsonApiRelationshipIds() leen el linkage del cuerpo de un endpoint de relación.
FormRequest: agrega el trait InteractsWithJsonApi y escribe las reglas sobre los datos planos del documento (los atributos en snake_case y, para cada relación, su objeto). validated() sirve para llenar un modelo y cada error sale con su source.pointer:
use Illuminate\Foundation\Http\FormRequest; use MundoProgra\JsonApi\Http\Requests\InteractsWithJsonApi; class StoreArticleRequest extends FormRequest { use InteractsWithJsonApi; public function authorize(): bool { return true; } public function rules(): array { return [ 'title' => ['required', 'string', 'max:50'], // /data/attributes/title 'status' => ['sometimes', 'in:draft,published'], // /data/attributes/status 'author.data.id' => ['integer'], // /data/relationships/author/data/id 'tags.data.*.type' => ['in:labels'], // /data/relationships/tags/data/0/type ]; } // El `type` que acepta este endpoint: otro es un 409. Y en PATCH/PUT, el `id` debe ser el de la URL // (jsonApiExpectedId(), por defecto el último parámetro de la ruta). protected function jsonApiExpectedType(): ?string { return 'articles'; } }
Errores
Todo error sale como {"errors": [...]} con status, title, detail, code y source (pointer, parameter o header), en el idioma de la app (español e inglés incluidos). El paquete se engancha al manejo de excepciones de Laravel y sale igual en las versiones 10 a 13; si la app registra su propio renderable, el de la app gana. Para lanzar uno a mano:
throw JsonApiException::make(status: 404, pointer: '/data/relationships/author/data');
El título, el detalle y el código se completan solos a partir del estado. Un error 5xx oculta sus detalles internos salvo que errors.expose_details (o app.debug) lo permita.
| Código base | Estado | Cuándo |
|---|---|---|
request.not_acceptable |
406 | El Accept no permite JSON:API (source.header) |
request.unsupported_media_type |
415 | El Content-Type no es JSON:API (source.header) |
request.bad_query |
400 | Un parámetro de consulta mal formado o no permitido (source.parameter) |
request.bad_document |
400 | El cuerpo no es JSON o no tiene la forma de un documento (source.pointer) |
request.bad_request |
400 | Una petición mal formada que no es de consulta ni de documento |
request.client_id_not_allowed |
403 | Un POST con id del cliente y allow_client_ids apagado |
auth.unauthenticated |
401 | Sin autenticar; nunca redirige al login |
auth.forbidden |
403 | Sin permiso |
auth.invalid_signature |
403 | La URL firmada es inválida o venció |
resource.not_found |
404 | El recurso no existe (no revela el nombre del modelo) |
route.not_found |
404 | La ruta no existe |
request.method_not_allowed |
405 | El método no está en la ruta; lleva Allow |
resource.conflict |
409 | El type o el id del documento no son los del endpoint |
request.payload_too_large |
413 | El cuerpo de la petición supera el límite |
request.token_mismatch |
419 | El token CSRF o de sesión venció |
validation.<regla> / validation.failed |
422 | Una regla de validación falló (validation.required, validation.max…); validation.failed si no hay nombre de regla |
request.too_many_requests |
429 | Límite de peticiones; lleva Retry-After |
server.error / server.unavailable |
500 / 503 | Error inesperado o servicio no disponible |
Cualquier otro estado HTTP usa http.<estado> (por ejemplo http.402). Los códigos llevan el prefijo mundoprogra.json-api. (por ejemplo mundoprogra.json-api.validation.required). Se cambian con errors.code_prefix, se reemplazan uno a uno con errors.codes o se apagan con errors.include_code.
El orden de las comprobaciones
Cuando una petición falla por más de un motivo, sale solo el primero, en este orden:
Content-Type(415) y despuésAccept(406).- La sintaxis de la consulta (
400). - El documento: JSON roto o mal formado (
400) o uniddel cliente (403). - La ruta: método no permitido (
405) o ruta que no existe (404). - La autenticación (
401). - El
FormRequest:typeoiddistintos (409) y después las reglas (422). - Tu controlador: la lista blanca de la consulta (
400) y el recurso que no existe (404).
Los pasos 1 a 3 corren antes que auth: sin sesión, un JSON roto es un 400 y no un 401.
Cómo se activa
Dentro del alcance (mode, path_prefix y except) el paquete agrega solo cuatro middleware a la pila global: json-api.apply (marca la petición y arregla el Accept para que auth responda 401 en lugar de buscar la ruta login), json-api.negotiate, json-api.query y json-api.document. Fuera de ese alcance no toca nada.
Para una ruta suelta fuera del alcance: ->middleware('json-api') aplica los cuatro. También existen por separado y json-api.options (por ejemplo json-api.options:per_page=25,max_per_page=50). json-api.document:relationship y :resource fuerzan el tipo de documento sin mirar la URL.
Configuración
Todo se puede definir en el .env. Con php artisan vendor:publish --tag="json-api-config" obtienes el archivo config/json-api.php.
| Opción | .env |
Por defecto | Qué hace |
|---|---|---|---|
mode |
JSON_API_MODE |
prefix |
prefix: solo las rutas con path_prefix. global: toda la app |
path_prefix |
JSON_API_PATH_PREFIX |
api |
Prefijo(s) de ruta (un texto o un arreglo) |
except |
— | ['up'] |
Rutas que el paquete nunca toca (admite comodines) |
strict_headers |
JSON_API_STRICT_HEADERS |
true |
Exige las cabeceras JSON:API; false también acepta application/json |
member_case |
JSON_API_MEMBER_CASE |
camel |
Estilo de los nombres: camel, kebab o snake |
jsonapi_object |
JSON_API_JSONAPI_OBJECT |
true |
Incluye {"jsonapi": {"version": "1.1"}} |
allow_client_ids |
JSON_API_ALLOW_CLIENT_IDS |
false |
Acepta el id que manda el cliente al crear |
allow_put |
JSON_API_ALLOW_PUT |
false |
También valida PUT como JSON:API |
reject_unknown_query_params |
JSON_API_REJECT_UNKNOWN_QUERY_PARAMS |
false |
Un parámetro desconocido de solo a-z responde 400 |
pagination.size |
JSON_API_PAGE_SIZE |
15 |
Tamaño de página por defecto |
pagination.max_size |
JSON_API_PAGE_MAX_SIZE |
100 |
Tope del page[size] del cliente |
pagination.on_exceed |
JSON_API_PAGE_ON_EXCEED |
clamp |
Pasado el tope: clamp lo recorta, error responde 400 |
query.max_include_depth |
JSON_API_MAX_INCLUDE_DEPTH |
3 |
Profundidad máxima de include |
links.route_name_prefix |
JSON_API_ROUTE_NAME_PREFIX |
vacío | Prefijo para resolver el nombre de las rutas de los links |
errors.expose_details |
JSON_API_EXPOSE_DETAILS |
app.debug |
Muestra el detalle real en los 500 |
errors.include_code |
JSON_API_INCLUDE_CODE |
true |
Envía code en cada error |
errors.code_prefix |
JSON_API_CODE_PREFIX |
mundoprogra.json-api |
Prefijo de los códigos |
errors.codes |
— | [] |
Reemplaza códigos por los tuyos |
errors.include_id |
JSON_API_INCLUDE_ID |
false |
Agrega un id único a cada error y lo escribe en el log |
La configuración se valida al arrancar: un valor inválido detiene la aplicación con un mensaje que lista todos los problemas a la vez.
Estilo de los nombres
member_case decide cómo se escriben los nombres de varias palabras en el documento: createdAt (camel), created-at (kebab) o created_at (snake). Se aplica a los atributos, las relaciones, sort, fields y el meta de paginación, en la entrada y en la salida. En tu código siempre escribes snake_case; el paquete convierte. Los tipos, los ids, los links y los punteros de los errores no cambian con el estilo.
Límites conocidos
- El paquete no registra rutas ni controladores; los endpoints (y su autorización) son tuyos.
- Se negocia JSON:API 1.1, pero las extensiones y los perfiles no se implementan: cualquier
extse rechaza con415y los perfiles se ignoran. Tampoco haylid. PUTno se valida salvo conallow_put; la spec solo definePATCHpara actualizar.- Un
includesobre una relación que no se cargó se ignora en silencio: la carga anticipada es dejsonApiInclude(). - Un paginador que implemente el contrato de Laravel pero no sea la clase
LengthAwarePaginatorde Illuminate no recibelinksnimeta. - Las reglas de cardinalidad (un identificador o una lista) y de tipo de un endpoint de relación las escribe la app, como en el ejemplo de arriba.
- El schema oficial de JSON:API que se usa en las pruebas es el de la versión 1.0.
Desarrollo y pruebas
No necesitas PHP instalado: todo se ejecuta en Docker. Cada servicio de docker-compose.yml corresponde a una versión de Laravel:
docker compose run --rm l13fast # Laravel 13: la verificación del día a día (rápido) docker compose run --rm l10 # Laravel 10 (PHP 8.1, la versión mínima) docker compose run --rm -e ARGS="--filter=Provider" l13fast # un subconjunto de pruebas docker compose run --rm -e STABILITY=prefer-lowest l12 # dependencias en su versión mínima
Servicios: l10, l11, l12, l13 (con el vendor del equipo, para el IDE) y l13fast (el mismo, con vendor en un volumen de Docker: unas ocho veces más rápido en Windows). Ejecútalos uno por uno, no en paralelo: comparten la carpeta del paquete.
Calidad de código (PHPStan al nivel 8 y Pint):
docker compose run --rm l13fast vendor/bin/phpstan analyse --no-progress docker compose run --rm l13fast vendor/bin/pint --test
Matriz completa de versiones (cada variante de PHP de cada Laravel, con dependencias estables y mínimas; tarda unos 40 minutos):
bin/matrix.sh # todas bin/matrix.sh l12-php8.4 # solo esa variante
La API de referencia que usan las pruebas de contrato (artículos, recursos relacionados y endpoints de relación, escritos como los escribiría una app) está en tests/Fixtures/ReferenceApi; sus pruebas validan cada respuesta contra el schema oficial de JSON:API.
Changelog
Consulta el CHANGELOG para ver los cambios recientes.
Seguridad
Para reportar una vulnerabilidad, revisa la política de seguridad.
Créditos
Licencia
Licencia MIT. Consulta el archivo LICENSE para más información.
English
A Laravel package that turns an API into an end-to-end JSON:API 1.1 service: it accepts and validates requests in the standard and responds in the standard, for both success and error responses.
Status: version
0.1.0, under validation. The API described here is complete, covered by tests on Laravel 10 to 13 and tried in a real application. In0.xthe API may change between minor versions (with^0.1Composer installs only0.1.x). The detail of each change is in the CHANGELOG.
What it does
- Responds in JSON:API: resources, paginated collections, relationships,
included,linksandmeta, and every error (validation,401,404,405and500included) as an error document withsource.pointerorsource.parameter. - Validates what arrives: the headers (
AcceptandContent-Type), the query parameters (include,fields,sort,filter,page) and the body document, with the exact pointer of the failing member. - Applies the query to Eloquent with whitelists you write: filters, sorting, pagination and eager loading of relationships.
- Registers no routes or controllers: you decide the URLs and the logic; the package takes care of the format.
- Switches itself on on install, without touching
bootstrap/app.php,KernelorHandler.
Requirements
- PHP 8.1 or higher
- Laravel 10, 11, 12 or 13
Installation
composer require mundoprogra/json-api
The package is auto-discovered. To change the configuration or the error texts:
php artisan vendor:publish --tag="json-api-config" php artisan vendor:publish --tag="json-api-lang"
Getting started
By default the package acts on routes starting with api (mode = prefix, path_prefix = api). Everything else answers as usual. To make the whole application respond in JSON:API, set JSON_API_MODE=global in .env.
1. A resource (what is shown of a model):
use Illuminate\Http\Request; use MundoProgra\JsonApi\Resources\Relationship; use MundoProgra\JsonApi\Resources\ResourceObject; class ArticleResource extends ResourceObject { // The short name of the route that shows an article: its `links.self` comes from there. protected function jsonApiRoute(): ?string { return 'articles.show'; } // Names are written in snake_case and come out in the configured style (`createdAt` by default). protected function jsonApiAttributes(Request $request): array { return [ 'title' => $this->title, 'status' => $this->status, 'created_at' => $this->created_at?->toIso8601String(), ]; } // Only loaded relationships come out: the package never fires a query to complete a document. protected function jsonApiRelationships(Request $request): array { return [ 'author' => $this->whenLoaded('author', fn() => Relationship::one($this->author, AuthorResource::class)), 'tags' => $this->whenLoaded('tags', fn() => Relationship::many($this->tags, TagResource::class)), ]; } }
The type of a resource is the model table and its id is the route key, always as text. Override type() and id() to change them.
2. A controller that applies the client query with whitelists:
class ArticleController { public function index() { return ArticleResource::collection( Article::query() ->jsonApiFilter(exact: ['status', 'author' => 'author_id'], partial: ['title'], scopes: ['published']) ->jsonApiSort(['id', 'title', 'created_at'], default: '-id') ->jsonApiInclude(['author', 'tags', 'comments.author']) ->jsonApiPaginate() ); } public function show(string $article) { return ArticleResource::make( Article::query()->jsonApiInclude(['author', 'tags'])->findOrFail($article) ); } }
With that, GET /api/articles?filter[status]=published&sort=-createdAt&include=author&fields[articles]=title,author&page[size]=5 already works end to end: it filters, sorts, paginates, loads the author without extra queries and returns the document with data, included, links and meta. Every filter, sort or include that is not in your list answers 400 naming the failing parameter.
Resources, collections and relationships
| You want | You write |
|---|---|
| A resource | ArticleResource::make($article) |
| A collection | ArticleResource::collection($articles) (an array, a collection or a length-aware paginator) |
| A paginated collection | ArticleResource::collection(Article::query()->jsonApiPaginate()) |
Your own links or meta |
jsonApiLinks($request) and jsonApiMeta($request) in the resource, or ->additional([...]) |
| A to-one or to-many relationship | Relationship::one($model, Class::class) / Relationship::many($models, Class::class) |
| A relationship without loading it | Relationship::unloaded()->links(...$this->relationshipLinks($request, 'author')) |
| The empty related resource | AuthorResource::make(null), which answers data: null |
- An empty collection is
data: []. Pagination addslinks(first,last,prev,next) andmeta.page(currentPage,perPage,lastPage,total), which keepsort,filter,includeandfieldsexactly as the client sent them. - A simple or cursor paginator is not accepted (it does not know how many pages there are) and throws a clear error.
relationshipLinks($request, 'author')builds{self}/relationships/authorand{self}/authorfrom the resource's ownlinks.self. It is optional.included: only what the client asked for withincludecomes out, from relationships that are already loaded and that the resource shows. Each resource appears once (bytypeandid) and never repeats those indata. What afields[type]hides is not included either, so the document keeps the spec's full linkage. To load relationships without N+1, usejsonApiInclude([...]).- Sparse fieldsets:
fields[articles]=titletrims the attributes and relationships of that type;type,id,linksandmetastay.
Relationship endpoints
A Relationship returned from a controller is the response of a relationship endpoint (GET /articles/1/relationships/author): {jsonapi, links, data, meta} with only the linkage (type and id).
public function author(string $article) { $model = Article::query()->with('author')->findOrFail($article); return Relationship::one($model->author, AuthorResource::class)->links( self: route('articles.relationships.author', [$model]), related: route('articles.author', [$model]), ); }
To write a relationship, read the linkage with RelationshipDocument (the package already validated its shape). What depends on your model you check yourself, with these codes:
use MundoProgra\JsonApi\Exceptions\JsonApiException; use MundoProgra\JsonApi\Http\Requests\RelationshipDocument; public function updateAuthor(Request $request, string $article) { $document = RelationshipDocument::of($request); // data() is null, an identifier or a list; isList() says which if ($document->isList()) { // a list where an identifier goes throw JsonApiException::make(status: 422, detail: 'A to-one relationship takes one identifier or null.', pointer: '/data'); } $identifier = $document->data(); // ['type' => 'authors', 'id' => '3'] or null if ($identifier !== null && $identifier['type'] !== 'authors') { throw JsonApiException::make(status: 409, pointer: '/data/type'); // the type is not the relationship's } $author = $identifier === null ? null : Author::find($identifier['id']) ?? throw JsonApiException::make(status: 404, pointer: '/data'); // the resource does not exist // ... associate, save and answer with the resulting relationship (`200`) }
PATCH replaces, POST adds and DELETE removes on a to-many relationship. Check everything before writing, so an error leaves no half-done changes. The full example is in tests/Fixtures/ReferenceApi.
Query parameters
| Eloquent builder macro | What it does |
|---|---|
jsonApiPaginate(?int $perPage = null, ?int $maxPerPage = null) |
Fetches the page the client asked for (page[number], page[size]) and returns Laravel's paginator. A page out of range is an empty list. |
jsonApiSort(array $allowed, ?string $default = null) |
Applies sort (- for descending) with a whitelist. It appends the primary key so pages are stable. It accepts ['author' => 'author_id'] to name a column differently. |
jsonApiFilter(exact: [], partial: [], scopes: []) |
exact: equality (with commas, an IN). partial: LIKE %value% with % and _ escaped. scopes: calls a scope of the model. A name outside the lists is 400. |
jsonApiInclude(array $allowed) |
Eager loads the relationships in include (['author', 'comments.author']; allowing a path allows its prefixes). A path outside the list is 400. |
The lists are written in snake_case (the columns and relationships of your model); the client sends them in the configured style.
Page size: it comes from the client (page[size]), otherwise from the jsonApiPaginate(perPage: 30) argument, otherwise from the middleware options (->middleware('json-api.options:per_page=25,max_per_page=50')) and, otherwise, from the config; always capped by the maximum.
$request->jsonApiQuery() gives the already parsed query (includes(), fields(), sort(), filter(), page()).
Reading the input document
The package validates the body of every JSON POST and PATCH before it reaches your controller, and the DELETE of a relationship endpoint too. To read it, the request has these methods (attribute and relationship names are in snake_case):
| Method | Returns |
|---|---|
$request->isJsonApi() |
Whether the package treats the request as JSON:API |
$request->jsonApiType() / jsonApiId() |
The type and id of the document (id is null if absent) |
$request->jsonApiAttributes() |
The attributes, with names in snake_case |
$request->hasJsonApiRelationship('author') |
Whether the client sent that relationship (even as data: null) |
$request->jsonApiRelationshipId('author') |
The id of a to-one relationship, or null |
$request->jsonApiRelationshipIds('tags') |
The ids of a to-many relationship, in order |
Without the name, jsonApiRelationshipId() and jsonApiRelationshipIds() read the linkage of the body of a relationship endpoint.
FormRequest: add the InteractsWithJsonApi trait and write the rules over the flat data of the document (attributes in snake_case and, for each relationship, its object). validated() can fill a model and each error comes out with its source.pointer:
use Illuminate\Foundation\Http\FormRequest; use MundoProgra\JsonApi\Http\Requests\InteractsWithJsonApi; class StoreArticleRequest extends FormRequest { use InteractsWithJsonApi; public function authorize(): bool { return true; } public function rules(): array { return [ 'title' => ['required', 'string', 'max:50'], // /data/attributes/title 'status' => ['sometimes', 'in:draft,published'], // /data/attributes/status 'author.data.id' => ['integer'], // /data/relationships/author/data/id 'tags.data.*.type' => ['in:labels'], // /data/relationships/tags/data/0/type ]; } // The `type` this endpoint accepts: any other is a 409. And on PATCH/PUT, the `id` must be the one in the URL // (jsonApiExpectedId(), by default the last route parameter). protected function jsonApiExpectedType(): ?string { return 'articles'; } }
Errors
Every error comes out as {"errors": [...]} with status, title, detail, code and source (pointer, parameter or header), in the app language (Spanish and English included). The package hooks into Laravel's exception handling and works the same on versions 10 to 13; if the app registers its own renderable, the app's wins. To throw one by hand:
throw JsonApiException::make(status: 404, pointer: '/data/relationships/author/data');
The title, detail and code are filled in from the status. A 5xx error hides its internals unless errors.expose_details (or app.debug) allows it.
| Base code | Status | When |
|---|---|---|
request.not_acceptable |
406 | The Accept does not allow JSON:API (source.header) |
request.unsupported_media_type |
415 | The Content-Type is not JSON:API (source.header) |
request.bad_query |
400 | A malformed or not allowed query parameter (source.parameter) |
request.bad_document |
400 | The body is not JSON or does not have the shape of a document (source.pointer) |
request.bad_request |
400 | A malformed request that is neither a query nor a document problem |
request.client_id_not_allowed |
403 | A POST with a client id and allow_client_ids off |
auth.unauthenticated |
401 | Not authenticated; it never redirects to login |
auth.forbidden |
403 | No permission |
auth.invalid_signature |
403 | The signed URL is invalid or expired |
resource.not_found |
404 | The resource does not exist (it does not reveal the model name) |
route.not_found |
404 | The route does not exist |
request.method_not_allowed |
405 | The method is not on the route; it carries Allow |
resource.conflict |
409 | The type or id of the document are not those of the endpoint |
request.payload_too_large |
413 | The request body exceeds the limit |
request.token_mismatch |
419 | The CSRF or session token expired |
validation.<rule> / validation.failed |
422 | A validation rule failed (validation.required, validation.max…); validation.failed when there is no rule name |
request.too_many_requests |
429 | Rate limit; it carries Retry-After |
server.error / server.unavailable |
500 / 503 | Unexpected error or service unavailable |
Any other HTTP status uses http.<status> (for example http.402). Codes carry the prefix mundoprogra.json-api. (for example mundoprogra.json-api.validation.required). Change it with errors.code_prefix, replace codes one by one with errors.codes, or turn them off with errors.include_code.
The order of the checks
When a request fails for more than one reason, only the first comes out, in this order:
Content-Type(415) and thenAccept(406).- The syntax of the query (
400). - The document: broken or malformed JSON (
400) or a clientid(403). - The route: method not allowed (
405) or a route that does not exist (404). - Authentication (
401). - The
FormRequest: a differenttypeorid(409) and then the rules (422). - Your controller: the query whitelist (
400) and a resource that does not exist (404).
Steps 1 to 3 run before auth: without a session, broken JSON is a 400, not a 401.
How it switches on
Inside the scope (mode, path_prefix and except) the package adds four middleware to the global stack on its own: json-api.apply (marks the request and fixes the Accept so auth answers 401 instead of looking up the login route), json-api.negotiate, json-api.query and json-api.document. Outside that scope it touches nothing.
For a single route outside the scope: ->middleware('json-api') applies all four. They also exist separately, plus json-api.options (for example json-api.options:per_page=25,max_per_page=50). json-api.document:relationship and :resource force the kind of document without looking at the URL.
Configuration
Everything can be set in .env. With php artisan vendor:publish --tag="json-api-config" you get the config/json-api.php file.
| Option | .env |
Default | What it does |
|---|---|---|---|
mode |
JSON_API_MODE |
prefix |
prefix: only routes with path_prefix. global: the whole app |
path_prefix |
JSON_API_PATH_PREFIX |
api |
Route prefix(es) (a string or an array) |
except |
— | ['up'] |
Routes the package never touches (wildcards allowed) |
strict_headers |
JSON_API_STRICT_HEADERS |
true |
Requires the JSON:API headers; false also accepts application/json |
member_case |
JSON_API_MEMBER_CASE |
camel |
Naming style: camel, kebab or snake |
jsonapi_object |
JSON_API_JSONAPI_OBJECT |
true |
Includes {"jsonapi": {"version": "1.1"}} |
allow_client_ids |
JSON_API_ALLOW_CLIENT_IDS |
false |
Accepts the id the client sends when creating |
allow_put |
JSON_API_ALLOW_PUT |
false |
Also validates PUT as JSON:API |
reject_unknown_query_params |
JSON_API_REJECT_UNKNOWN_QUERY_PARAMS |
false |
An unknown parameter made only of a-z answers 400 |
pagination.size |
JSON_API_PAGE_SIZE |
15 |
Default page size |
pagination.max_size |
JSON_API_PAGE_MAX_SIZE |
100 |
Upper limit for the client page[size] |
pagination.on_exceed |
JSON_API_PAGE_ON_EXCEED |
clamp |
Over the limit: clamp trims it, error answers 400 |
query.max_include_depth |
JSON_API_MAX_INCLUDE_DEPTH |
3 |
Maximum include depth |
links.route_name_prefix |
JSON_API_ROUTE_NAME_PREFIX |
empty | Prefix to resolve the route names of links |
errors.expose_details |
JSON_API_EXPOSE_DETAILS |
app.debug |
Shows the real detail in 500 errors |
errors.include_code |
JSON_API_INCLUDE_CODE |
true |
Sends code in every error |
errors.code_prefix |
JSON_API_CODE_PREFIX |
mundoprogra.json-api |
Prefix of the codes |
errors.codes |
— | [] |
Replaces codes with your own |
errors.include_id |
JSON_API_INCLUDE_ID |
false |
Adds a unique id to each error and writes it to the log |
The configuration is validated on boot: an invalid value stops the application with a message that lists every problem at once.
Naming style
member_case decides how multi-word names are written in the document: createdAt (camel), created-at (kebab) or created_at (snake). It applies to attributes, relationships, sort, fields and the pagination meta, on input and output. In your code you always write snake_case; the package converts. Types, ids, links and error pointers do not change with the style.
Known limits
- The package registers no routes or controllers; the endpoints (and their authorization) are yours.
- JSON:API 1.1 is negotiated, but extensions and profiles are not implemented: any
extis rejected with415and profiles are ignored. There is nolideither. PUTis not validated unlessallow_putis on; the spec only definesPATCHfor updates.- An
includeon a relationship that was not loaded is silently ignored: eager loading isjsonApiInclude()'s job. - A paginator that implements Laravel's contract but is not Illuminate's
LengthAwarePaginatorclass gets nolinksormeta. - The cardinality rules (an identifier or a list) and type rules of a relationship endpoint are written by the app, as in the example above.
- The official JSON:API schema used in the tests is the one for version 1.0.
Development and testing
You don't need PHP installed: everything runs in Docker. Each service in docker-compose.yml maps to a Laravel version:
docker compose run --rm l13fast # Laravel 13: the day-to-day check (fast) docker compose run --rm l10 # Laravel 10 (PHP 8.1, the minimum version) docker compose run --rm -e ARGS="--filter=Provider" l13fast # a subset of tests docker compose run --rm -e STABILITY=prefer-lowest l12 # dependencies at their lowest versions
Services: l10, l11, l12, l13 (with the host vendor, for the IDE) and l13fast (the same, with vendor in a Docker volume: about eight times faster on Windows). Run them one at a time, not in parallel: they share the package folder.
Code quality (PHPStan at level 8, and Pint):
docker compose run --rm l13fast vendor/bin/phpstan analyse --no-progress docker compose run --rm l13fast vendor/bin/pint --test
Full version matrix (every PHP variant of every Laravel, with stable and lowest dependencies; it takes about 40 minutes):
bin/matrix.sh # all of them bin/matrix.sh l12-php8.4 # only that variant
The reference API used by the contract tests (articles, related resources and relationship endpoints, written the way an app would write them) is in tests/Fixtures/ReferenceApi; its tests validate every response against the official JSON:API schema.
Changelog
See the CHANGELOG for recent changes.
Security
To report a vulnerability, please review the security policy.
Credits
License
The MIT License (MIT). Please see the License File for more information.