Saltearse al contenido

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.

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.

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.

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.

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:

Ventana de terminal
CONTRACT_RECORD=1 php artisan test
php artisan test --filter=AdditiveContractTest

Nunca CONTRACT_RECORD=1 con --filter ni con --parallel: el archivo queda truncado a lo que haya corrido.

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.

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.

  • Renombrar una relación sin tocar su relation(). relationLoaded() da falso y la clave desaparece. Es una retirada que no se ve como un rename.
  • Recortar las columnas de una consulta. payload() descarta lo que no se cargó: un get(['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 lee current_page o links en 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 --filter o --parallel el archivo queda a medias: se restaura desde git y se repite la suite completa.