Contrato
La forma de una respuesta la decide un Resource, no el modelo de Eloquent. Esa capa separa la forma pública del JSON del nombre de la columna: renombrar una columna no cambia el contrato. Esta página explica la capa, el sobre del paginador y el archivo que congela la forma.
La regla de fondo: el contrato solo puede crecer.
Cómo funciona
Sección titulada «Cómo funciona»Resources y payload()
Sección titulada «Resources y payload()»Los Resources viven en app/Http/Resources/ y en app-modules/*/Http/Resources/. Un controlador no devuelve un modelo directo: devuelve un Resource, y el Resource declara qué claves salen.
ApiResource::payload() emite solo las claves cuya columna fue cargada. Los endpoints cargan subconjuntos distintos (category:id,name en un listado, category:id,name,slug en el detalle), así que la misma entidad puede salir con juegos de claves distintos. No asumas el juego completo.
ApiResource::relation() emite una relación solo si fue eager-loaded. Si nadie la cargó, la clave no aparece, y no se dispara una query perezosa. Acepta una clave de salida distinta del nombre de la relación.
Sin envoltorio data
Sección titulada «Sin envoltorio data»JsonResource::withoutWrapping() está activo en AppServiceProvider. El recurso sale en la raíz: GET /api/leads/{lead} devuelve las claves del lead en la raíz, sin un data que las envuelva.
El sobre del paginador
Sección titulada «El sobre del paginador»Los listados paginados usan paginate(...)->through(...). through() mapea los ítems y conserva el sobre de Laravel:
{ "current_page": 1, "data": [ ... ], "first_page_url": "…?page=1", "from": 1, "last_page": 4, "last_page_url": "…?page=4", "links": [{ "url": null, "label": "« Previous", "active": false }], "next_page_url": "…?page=2", "path": "https://api.merez.co/api/v1/blog-posts", "per_page": 20, "prev_page_url": null, "to": 20, "total": 73}Devolver Resource::collection($paginador) cambiaría ese sobre por el de Resources (data/links/meta), que no es el contrato vigente. per_page lo topa Controller::perPage() entre 1 y 100, con 50 por defecto donde el listado no fija otro valor.
El archivo de contrato
Sección titulada «El archivo de contrato»tests/Fixtures/api-contract.json registra, por endpoint y código de estado, la forma del JSON: rutas de claves anidadas y tipo del escalar. Lo escribe ContractRecorder mientras corre la suite, solo con CONTRACT_RECORD=1. Es determinista: acumula la unión de lo que cada test vio.
AdditiveContractTest compara contra tests/Fixtures/api-contract-baseline.json, una foto congelada, y falla diciendo qué desapareció. Una retirada deliberada se anota en ALLOWED_REMOVALS y en la tabla de docs/api/contract.md; no se hace por la puerta de atrás.
Verificar son dos pasos, porque el archivo se escribe al terminar el proceso:
CONTRACT_RECORD=1 php artisan testphp artisan test --filter=AdditiveContractTestNunca CONTRACT_RECORD=1 con --filter ni con --parallel: el archivo queda truncado a lo que haya corrido.
El spec publicado
Sección titulada «El spec publicado»php artisan docs:openapi deriva un OpenAPI 3.1 del archivo de contrato, no de las rutas, y lo escribe en public/docs/openapi.json. Se commitea; OpenApiPublishedTest y el CI fallan si el commiteado difiere del regenerado. El spec hereda la cobertura del fixture: un endpoint que ningún test ejercita no sale.
Ejemplo
Sección titulada «Ejemplo»class ProductResource extends ApiResource{ public function toArray(Request $request): array { return array_merge( // resumido: la clase real emite más bloques $this->payload([ 'id' => 'id', 'site_id' => 'site_id', 'name' => 'name', 'slug' => 'slug', ]), $this->relation('category', CategoryResource::class), ); }}A la izquierda va la clave pública; a la derecha, la columna. relation() añade category solo si vino cargada.
Qué rompe
Sección titulada «Qué rompe»- Renombrar una relación sin tocar su
relation().relationLoaded()da falso y la clave desaparece. Es una retirada que no se ve como unrename. - Recortar las columnas de una consulta.
payload()descarta lo que no se cargó: unget(['id','name'])deja de servir las demás claves, aunque el Resource las declare. - Devolver
Resource::collection($paginador). Cambia el sobre y rompe a quien leecurrent_pageolinksen la raíz. - Quitar una clave o cambiar su tipo. El test aditivo falla. Estrechar un tipo también cuenta.
- Cambiar los datos sembrados. El grabador deduce el tipo del valor que ve; un seeder distinto puede estrechar el contrato sin tocar la API.
- Regenerar mal. Con
--filtero--parallelel archivo queda a medias: se restaura desde git y se repite la suite completa.
Ver también
Sección titulada «Ver también»- Autenticación: qué credencial abre cada ruta.
- Errores: el 422 también es contrato.
- Versionado: qué cambio obliga a subir de versión.