API Resources
Com transformar models Eloquent en respostes JSON amb API Resources, Resource Collections i atributs condicionals.
Què són les API Resources?#
Les API Resources són una capa de transformació entre els models Eloquent i les respostes JSON de la teva API. Defineixen un contracte clar entre el backend i els clients: independentment de com canviï l'estructura interna de la base de dades, la resposta JSON manté el format que els clients esperen.
Sense Resources, retornar un model directament és perillós. El model inclou totes les columnes de la taula: password, remember_token, email_verified_at, camps interns de moderació o dades sensibles que no haurien de ser visibles a l'API. A més, si renombres una columna a la base de dades, la resposta JSON canvia automàticament i pot trencar tots els clients que la consumeixen.
Amb Resources, defineixes explícitament l'estructura JSON de cada resposta. Pots renombrar camps, formatar dates, calcular valors derivats, incloure relacions de manera condicional i diferenciar la informació que veu un administrador de la que veu un usuari normal. La base de dades pot canviar internament sense que l'API externa se'n vegi afectada.
Crear un Resource#
php artisan make:resource ArticleResourceAixò genera una classe a app/Http/Resources/ArticleResource.php. El mètode toArray defineix l'estructura JSON:
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ArticleResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'body' => $this->body,
'excerpt' => str($this->body)->limit(200),
'is_published' => $this->is_published,
'published_at' => $this->published_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}Dins del Resource, $this fa referència al model que s'està transformant gràcies a un proxy intern. Pots accedir a qualsevol atribut, accessor o relació del model com si el Resource fos el model directament. Fixa't que les dates es formategen amb toISOString() (format ISO 8601), que és l'estàndard per a APIs JSON perquè és independent de la zona horària i fàcilment parsejable per qualsevol client.
L'excerpt es calcula al vol amb str()->limit(), un exemple de com el Resource pot derivar camps que no existeixen al model. D'altra banda, camps com password, remember_token o qualsevol columna interna simplement no s'inclouen a l'array, així que mai arriben a la resposta JSON.
Utilitzar Resources#
Un sol recurs#
Per retornar un sol model transformat:
use App\Http\Resources\ArticleResource;
use App\Models\Article;
public function show(Article $article)
{
return new ArticleResource($article->load('author', 'tags'));
}La resposta JSON tindrà aquesta estructura:
{
"data": {
"id": 1,
"title": "Primer article",
"slug": "primer-article",
"body": "El contingut de l'article...",
"excerpt": "El contingut de l'ar...",
"is_published": true,
"published_at": "2024-01-15T10:30:00.000000Z",
"created_at": "2024-01-15T10:00:00.000000Z",
"updated_at": "2024-01-15T10:30:00.000000Z"
}
}Les dades s'embolcallen automàticament dins d'una clau data. Aquest és un patró estàndard a les APIs JSON.
Col·leccions#
Per retornar una llista de models:
public function index()
{
$articles = Article::with('author')->latest()->paginate(15);
return ArticleResource::collection($articles);
}Quan passes una col·lecció paginada, Laravel inclou automàticament la informació de paginació:
{
"data": [
{ "id": 1, "title": "Primer article", ... },
{ "id": 2, "title": "Segon article", ... }
],
"links": {
"first": "http://api.example.com/articles?page=1",
"last": "http://api.example.com/articles?page=5",
"prev": null,
"next": "http://api.example.com/articles?page=2"
},
"meta": {
"current_page": 1,
"from": 1,
"last_page": 5,
"per_page": 15,
"to": 15,
"total": 73,
"path": "http://api.example.com/articles"
}
}Codi d'estat personalitzat#
Per canviar el codi d'estat HTTP de la resposta:
public function store(StoreArticleRequest $request)
{
$article = Article::create($request->validated());
return (new ArticleResource($article))
->response()
->setStatusCode(201);
}Relacions#
Un dels punts forts de les API Resources és com gestionen les relacions. La idea és que cada model tingui el seu propi Resource (ArticleResource, UserResource, TagResource, etc.) i que es composin entre ells. Així, el format d'un autor és sempre el mateix, tant si apareix dins d'un article com si es retorna directament.
El mètode whenLoaded és fonamental: indica que la relació només s'ha d'incloure a la resposta si ja ha estat carregada prèviament amb load o with. Si la relació no s'ha carregat, la clau senzillament no apareix a la resposta JSON. Això evita dues coses: consultes N+1 accidentals (si accedissis a una relació no carregada, Eloquent faria una consulta per cada model) i dades innecessàries a les respostes de llistes on potser no necessites les relacions:
class ArticleResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'body' => $this->body,
'author' => new UserResource($this->whenLoaded('author')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
'category' => new CategoryResource($this->whenLoaded('category')),
'published_at' => $this->published_at?->toISOString(),
];
}
}Aquesta flexibilitat és molt poderosa. El Resource defineix totes les relacions possibles, però el controlador decideix en cada cas quines es carreguen. Una llista d'articles potser només necessita l'autor, mentre que el detall d'un article carrega l'autor, les etiquetes, la categoria i els comentaris:
// Sense relacions (per a llistes)
return ArticleResource::collection(Article::paginate(15));
// Amb l'autor (per a la llista amb noms)
return ArticleResource::collection(Article::with('author')->paginate(15));
// Amb tot (per al detall)
return new ArticleResource(
$article->load(['author', 'tags', 'comments.author', 'category'])
);Comptadors de relacions#
De vegades no necessites les dades completes de la relació, sinó només quants registres hi ha. Per exemple, mostrar "23 comentaris" a la llista d'articles sense carregar tots els comentaris. El mètode whenCounted funciona igual que whenLoaded, però per a comptadors carregats amb withCount:
return [
'id' => $this->id,
'title' => $this->title,
'comments_count' => $this->whenCounted('comments'),
'likes_count' => $this->whenCounted('likes'),
];Al controlador:
$articles = Article::withCount(['comments', 'likes'])->paginate(15);Relacions pivot#
Les relacions many-to-many sovint tenen dades addicionals a la taula pivot (per exemple, la data en què es va assignar una etiqueta a un article, o el rol d'un usuari dins d'un equip). El mètode whenPivotLoaded permet incloure dades de la taula pivot de manera condicional, indicant el nom de la taula pivot i una closure que accedeix a les dades:
class TagResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'assigned_at' => $this->whenPivotLoaded('article_tag', function () {
return $this->pivot->created_at->toISOString();
}),
];
}
}Atributs condicionals#
No totes les dades d'un recurs han de ser visibles per a tothom ni en totes les circumstàncies. Un administrador pot necessitar veure camps de moderació que un usuari normal no hauria de veure. Un article en esborrany pot tenir informació diferent d'un de publicat. Les API Resources proporcionen mètodes per incloure atributs de manera condicional, sense necessitar Resources diferents per a cada cas d'ús.
when#
El mètode when inclou un atribut a la resposta només si es compleix una condició. Si la condició és false, la clau directament no apareix al JSON (no és que el valor sigui null, sinó que la clau no existeix):
return [
'id' => $this->id,
'title' => $this->title,
'body' => $this->body,
// Només visible per a administradors
'internal_notes' => $this->when(
$request->user()?->role === 'admin',
$this->internal_notes
),
// Només si l'article està publicat
'published_at' => $this->when(
$this->is_published,
$this->published_at?->toISOString()
),
// Amb valor per defecte
'status' => $this->when(
$this->is_published,
'published',
'draft'
),
];mergeWhen#
Quan vols incloure un grup d'atributs sota la mateixa condició (per exemple, tots els camps de moderació només per a administradors), mergeWhen és més net que repetir when per a cada camp. Afegeix tots els atributs de l'array si la condició és certa:
return [
'id' => $this->id,
'title' => $this->title,
$this->mergeWhen($request->user()?->role === 'admin', [
'internal_notes' => $this->internal_notes,
'created_by_ip' => $this->created_by_ip,
'moderation_status' => $this->moderation_status,
]),
];whenHas#
Per incloure un atribut només si existeix al model (útil per a atributs que es carreguen condicionalment):
return [
'id' => $this->id,
'title' => $this->title,
'average_rating' => $this->whenHas('average_rating'),
];Resource Collections#
En la majoria de casos, el mètode estàtic ArticleResource::collection() és suficient per transformar una llista de models. Laravel aplica l'ArticleResource a cada element de la col·lecció automàticament i gestiona la paginació sense cap configuració addicional.
Però quan necessites afegir metadades a la col·lecció que no pertanyen a cap element individual (com estadístiques, totals per estat o informació de filtres aplicats), pots crear un Resource Collection dedicat:
php artisan make:resource ArticleCollectionnamespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ArticleCollection extends ResourceCollection
{
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'meta' => [
'total_articles' => $this->total(),
'published_count' => $this->collection->where('is_published', true)->count(),
'draft_count' => $this->collection->where('is_published', false)->count(),
],
];
}
}Utilitza'l al controlador:
public function index()
{
return new ArticleCollection(Article::paginate(15));
}Especificar el Resource de cada element#
Per defecte, Laravel infereix el Resource individual a partir del nom de la col·lecció. Si vols especificar-lo explícitament:
class ArticleCollection extends ResourceCollection
{
public $collects = ArticleResource::class;
}Dades addicionals#
De vegades necessites incloure informació a la resposta que no pertany a cap model concret: la versió de l'API, el temps de generació de la resposta, o informació contextual com els filtres aplicats o l'estat de la cache. Laravel ofereix dues maneres d'afegir dades addicionals a les respostes dels Resources.
additional#
El mètode additional permet afegir dades extres a una resposta concreta, des del controlador:
return (new ArticleResource($article))
->additional([
'meta' => [
'version' => '1.0',
'generated_at' => now()->toISOString(),
],
]);with#
Per afegir dades extres a totes les respostes d'un Resource, sobreescriu el mètode with:
class ArticleResource extends JsonResource
{
public function with(Request $request): array
{
return [
'meta' => [
'api_version' => 'v1',
],
];
}
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
];
}
}Desactivar l'embolcall data#
Per defecte, Laravel embolcalla les dades de cada Resource dins d'una clau data. Aquest patró és una convenció estesa a les APIs JSON perquè permet afegir metadades al mateix nivell que les dades (com links, meta, message) sense ambigüitat. Tanmateix, si estàs construint una API que ha de ser compatible amb un contracte existent que no utilitza aquest embolcall, pots desactivar-lo:
// Per a un Resource concret
class ArticleResource extends JsonResource
{
public static $wrap = null;
}
// O globalment al AppServiceProvider
use Illuminate\Http\Resources\Json\JsonResource;
public function boot(): void
{
JsonResource::withoutWrapping();
}Desactivar l'embolcall data fa que les col·leccions paginades no puguin incloure les claus links i meta, ja que no hi ha manera de separar-les de les dades. Només desactiva'l si tens una bona raó.
Resources per a respostes de creació i actualització#
Un patró recomanat i àmpliament adoptat a les APIs REST és retornar sempre el Resource transformat després de crear o actualitzar un model. Això permet que el client tingui immediatament les dades actualitzades sense necessitar una segona petició. La resposta de creació inclou l'ID generat pel servidor, els timestamps (created_at, updated_at), els valors per defecte aplicats per la base de dades i qualsevol camp calculat per mutadors o accessors:
public function store(StoreArticleRequest $request)
{
$article = Article::create($request->validated());
return (new ArticleResource($article->load('author')))
->response()
->setStatusCode(201);
}
public function update(UpdateArticleRequest $request, Article $article)
{
$article->update($request->validated());
return new ArticleResource($article->fresh(['author', 'tags']));
}El mètode fresh() torna a carregar el model des de la base de dades amb les relacions indicades, assegurant que la resposta reflecteix l'estat real després de l'actualització.
Exemple complet#
Un Resource complet per a un blog podria ser:
class ArticleResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => str($this->body)->limit(200),
'body' => $this->when(
$request->routeIs('api.articles.show'),
$this->body
),
'is_published' => $this->is_published,
// Relacions
'author' => new UserResource($this->whenLoaded('author')),
'category' => new CategoryResource($this->whenLoaded('category')),
'tags' => TagResource::collection($this->whenLoaded('tags')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
// Comptadors
'comments_count' => $this->whenCounted('comments'),
'likes_count' => $this->whenCounted('likes'),
// Només per a admins
$this->mergeWhen($request->user()?->role === 'admin', [
'internal_notes' => $this->internal_notes,
'moderation_status' => $this->moderation_status,
]),
// Dates
'published_at' => $this->published_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}Fixa't que el body complet només s'inclou a la ruta de detall (show), no a les llistes. Això redueix la mida de les respostes de la llista sense necessitar un Resource separat.