Returning a raw Eloquent model from a controller works right up until the API has more than one consumer: a mobile app that needs a different shape than the web frontend, a field that shouldn't leak to unauthenticated requests, or a related model that needs eager loading conditionally. Laravel's API Resources are the transformation layer between your database schema and your API's actual public contract, and they exist specifically so that contract doesn't drift every time the schema does.
What a Resource class actually does
A Resource is a class with a toArray() method that receives the underlying model (or any data) and returns the array that gets JSON-encoded in the response. Generated with php artisan make:resource OrderResource, it wraps a single model; make:resource OrderCollection or the automatic collection behavior wraps a paginator or collection of them. The key benefit over returning $model->toArray() directly is that the resource's shape is decoupled from the database columns — you can rename, compute, or nest fields in the API response without touching the migration or the Eloquent model.
class OrderResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'reference' => $this->reference_number,
'status' => $this->status->value,
'total' => (float) $this->total,
'placed_at' => $this->created_at->toIso8601String(),
'customer' => new CustomerResource($this->whenLoaded('customer')),
'lines' => OrderLineResource::collection($this->whenLoaded('lines')),
];
}
}
Conditional attributes: when and whenLoaded
when() and whenLoaded() are the two methods that make resources genuinely useful instead of just a renaming layer. whenLoaded('customer') only includes the nested customer resource if that relationship was actually eager-loaded on the model — silently omitting the key rather than triggering a lazy-loaded N+1 query when the controller didn't eager-load it. when($condition, $value) conditionally includes any field, most commonly for authorization: showing an internal cost field only when($request->user()->isAdmin(), ...).
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'total' => (float) $this->total,
'cost_price' => $this->when(
$request->user()?->can('viewCosts', $this->resource),
fn () => (float) $this->cost_price
),
'customer' => new CustomerResource($this->whenLoaded('customer')),
];
}
whenLoaded() only avoids triggering a query if the relationship genuinely wasn't loaded — it doesn't eager-load anything for you. The eager loading still has to happen in the controller or query builder (Order::with('customer', 'lines')) before the resource runs; skip that and you're back to an N+1 query per resource instance, just without a crash, since whenLoaded silently omits the field instead of lazy-loading it in some configurations but will still trigger a query in others depending on how the relation was accessed.
Resource collections and pagination metadata
Calling OrderResource::collection($orders) wraps a collection or paginator, and if $orders is a paginator, Laravel automatically adds a links and meta block to the JSON response with pagination details (current page, total, per-page) alongside the transformed data array — without you writing any of that pagination-serialization logic by hand.
public function index(Request $request)
{
$orders = Order::with('customer', 'lines')
->latest()
->paginate(20);
return OrderResource::collection($orders);
}
Wrapping the response with additional top-level data
A resource or collection can attach extra top-level metadata that isn't part of any single model — a summary total, a request-scoped flag — with additional(), or by overriding with() on the resource class. This is the mechanism for envelope-style responses ({ "data": [...], "meta": {...} }) that need fields computed across the whole result set rather than per-item.
Wrapping up
API Resources exist to put one deliberate transformation layer between Eloquent models and the JSON an API actually returns, so the public response shape can evolve independently of the database schema. Use whenLoaded() paired with actual eager loading to avoid N+1s, when() for authorization-gated fields, and let collection/pagination handling do the metadata work instead of hand-rolling envelope structures per endpoint.
Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.