Designing the output — deciding the shape of a response with an API Resource
So far we have returned the model as the response.
return response()->json($order, 201);
It works, and it is convenient during development. This chapter covers why you must not keep doing it, and what to write instead.
Why you must not return the model directly
response()->json($order) converts every column of the Order model into JSON. The columns of the orders table from chapter 3 line up as they are.
{
"id": 1,
"customer_email": "taro.test@example.com",
"status": "pending",
"total_amount": 12800,
"created_at": "2026-08-07T10:00:00.000000Z",
"updated_at": "2026-08-07T10:00:00.000000Z"
}
Six keys come out, counting id and the timestamps. The problem is that this shape changes on its own, in lockstep with the table definition.
Six months later, the operations team asks to take notes on phone enquiries. You add an internal_memo column in a migration. A column was merely added to a table, and it appears in the API response.
{
"id": 1,
"customer_email": "taro.test@example.com",
"internal_memo": "Handling a complaint. Aggressive tone. Escalated to a manager",
"status": "pending",
"total_amount": 12800
}
The person who added the column never imagined it would be published. They wrote the migration, it passed review, and the tests passed. It gets published with nobody noticing.
Adding it to $hidden would hide it, but that is the "enumerate what to hide" approach. It leaves you with a process where every new column requires deciding whether it needs hiding and remembering to write it down. Forget once and it leaks.
Do it the other way around. Enumerate what you publish. A column you have not enumerated never leaves the application, no matter what the table gains.
Creating an API Resource
Laravel provides a place to write that enumeration.
php artisan make:resource OrderResource
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OrderResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'customer_email' => $this->customer_email,
'status' => $this->status,
'total_amount' => $this->total_amount,
'ordered_at' => $this->created_at,
];
}
}
In the controller, return this instead of the model.
public function store(StoreOrderRequest $request, OrderPlacementService $service)
{
$validated = $request->validated();
$order = $service->place($validated['customer_email'], $validated['items']);
return (new OrderResource($order))
->response()
->setStatusCode(201);
}
Only the keys written in toArray() become JSON. Add internal_memo and it does not come out unless you write it here.
Notice too that key names can differ from table column names. The example above publishes created_at as ordered_at. You can decouple internal convenience from the names you show clients. Refactor a table column name and the Resource absorbs it, leaving the API unchanged.
Including relationships
For order detail, you will want the lines returned too.
php artisan make:resource OrderItemResource
public function toArray(Request $request): array
{
return [
'product_id' => $this->product_id,
'quantity' => $this->quantity,
'unit_price' => $this->unit_price,
'subtotal' => $this->unit_price * $this->quantity,
];
}
Call it from OrderResource.
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'customer_email' => $this->customer_email,
'status' => $this->status,
'total_amount' => $this->total_amount,
'items' => OrderItemResource::collection($this->whenLoaded('items')),
'ordered_at' => $this->created_at,
];
}
As with subtotal, you can return values that are not in the table. A subtotal computed from unit price and quantity is kinder coming from the API than made the client's job. When the calculation changes, the fix is in one place.
Why use whenLoaded
$this->whenLoaded('items') includes the relationship only when it has already been loaded1.
The difference from writing $this->items is large. Written plainly, every conversion of the Resource fires a query to fetch the lines. Return 100 orders in a listing and you get 100 extra SELECTs for lines.
With whenLoaded, the key disappears from the output entirely when the relationship is not loaded. If the caller needs the lines, it loads them first.
// Listing: no lines returned
$orders = Order::paginate(20);
// Detail: lines returned too
$order = Order::with('items')->findOrFail($id);
How this "load it first" works and why it is necessary is covered in the next chapter. For now, get into the habit of using whenLoaded.
Showing fields conditionally
Even on the same resource, you may want the fields to differ by who is looking.
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status,
'total_amount' => $this->total_amount,
'internal_memo' => $this->when($request->user()?->isAdmin(), $this->internal_memo),
];
}
If when()'s first argument is false, that key does not appear in the output at all1. It is not set to null; the key is gone.
This does need care, though. One Resource serving several audiences accumulates conditions and gets hard to read. Somewhere past five admin-only fields, splitting off an AdminOrderResource reads better.
Data wrapping
A Resource you return is wrapped in a data key by default1.
{
"data": {
"id": 1,
"status": "pending"
}
}
It looks superfluous, but it has a purpose. Pagination information and metadata get a place that does not mix with the body. When we build the listing API in the next chapter, it looks like this.
{
"data": [ ... ],
"links": { "first": "...", "next": "..." },
"meta": { "current_page": 1, "total": 87 }
}
Without data, a key like total sits at the same level as the product's fields. The day you want to add a total field to a product, they collide.
You can turn it off.
use Illuminate\Http\Resources\Json\JsonResource;
public function boot(): void
{
JsonResource::withoutWrapping();
}
This series does not turn it off. We need somewhere to put pagination metadata.
Decide whether to turn it off before you publish. Switch it after publishing and every client breaks. One level of the response hierarchy changes, so code reading response.data.id has to read response.id.
Deciding how things are represented
Creating a Resource means deciding how each value is shown. Without deciding, the shape varies from chapter to chapter.
Dates
Laravel's default is ISO 8601.
"ordered_at": "2026-08-07T10:00:00.000000Z"
Because the time zone is stated explicitly as Z (UTC), the client can convert to local time. That is safer than an API returning a string in Japan time. Return only "2026-08-07 19:00" and no one can tell which time zone it is without reading the documentation.
When you want a formatted version for display, you can return both.
'ordered_at' => $this->created_at->toIso8601String(),
'ordered_at_label' => $this->created_at->timezone('Asia/Tokyo')->format('j F Y H:i'),
Amounts
Chapter 3 settled on integer yen. Return them as integers.
"total_amount": 12800
Avoid having the API return a pre-formatted string like "¥12,800". It becomes unusable for calculation, and the currency symbol and digit grouping are the display side's concern. If you localize, all the more so.
Enums
status was cast to OrderStatus in chapter 3. Return it directly from the Resource and you get the backed enum's value.
"status": "pending"
Clients branch on that string. Whether to return a display label alongside it is a judgment call.
'status' => $this->status->value,
'status_label' => match ($this->status) {
OrderStatus::Pending => 'Received',
OrderStatus::Confirmed => 'Confirmed',
OrderStatus::Shipped => 'Shipped',
OrderStatus::Cancelled => 'Cancelled',
},
Holding the label server-side means you can change the wording without waiting for a client release. If you localize, though, holding it client-side is more natural. This series returns only the value and leaves the label to the display side.
null and empty arrays
Standardize how "there is no value" is expressed.
- Return
nullfor a field with no value. Removing the key entirely is only for deliberate uses ofwhen() - Return
[]for an empty array. Nevernull
Make an array null and the client needs a check before every for loop. An empty array simply loops zero times.
An option: JSON:API resources
Laravel 13 has a mechanism for creating Resources that follow the JSON:API specification2.
php artisan make:resource OrderResource --json-api
JSON:API is a specification that fixes the response structure in detail, and it earns its keep when there are several kinds of client. Features such as "the client specifies whether to include relations" and "the client picks which fields come back" are settled by the specification, so implementations do not each invent their own shape.
On the other hand, the fixed structure makes the output verbose. For an API used by a single frontend, the plain JsonResource is often enough.
This series uses the plain JsonResource. Choose JSON:API based on how many clients you have and whether conforming to the specification is worth the structural complexity. For details, see Eloquent: API Resources.
What matters in production
You can add fields, but you cannot remove them
Remove a field from a published API response and the clients reading it break. Renaming is the same.
Adding does not break anything. An unknown key is simply ignored by the client.
That asymmetry sets your design posture. When in doubt, leave it out. You can add it later if it turns out to be needed. The reverse — publish it just in case and remove it later — is not available.
Be deliberate about exposing internal IDs
Return a foreign key such as user_id or product_id as is, and the client assumes it can call another endpoint with that ID. If it cannot, there is no point returning it.
If you return it, provide the endpoint that accepts it. If you do not, expand the information the client needs into the response.
'product_id' => $this->product_id, // assumes /api/v1/products/{id} is callable
'product' => new ProductResource($this->whenLoaded('product')), // expanded inline
Do not issue queries inside a Resource
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'product_name' => Product::find($this->product_id)->name, // one SELECT per record
];
}
A Resource is responsible for conversion only. Write a query here and returning 100 records in a listing issues it 100 times. Worse, it is invisible from the caller, making it a hard place to spot when hunting for what is slow.
Load the data you need in the controller or service class first. The next chapter covers this in detail.
Do not change the date format partway through
Mix ISO 8601 with Y-m-d H:i:s and the client's parsing has to branch. Decide once and keep it consistent across every Resource. With consistency, the client needs only one conversion function.
Summary
- Return the model directly and a new table column is published the moment it is added. Enumerate what you publish, not what you hide
- Resource key names are decoupled from table column names. Rename internally and the API holds
- With
whenLoaded, a relationship that is not loaded disappears key and all datawrapping gives pagination metadata somewhere to live. Switch it after publishing and every client breaks- Dates in ISO 8601, amounts as integers, enums as values. Formatting is the client's job
- You can add fields but not remove them. When in doubt, leave it out
What to read next
The next chapter, The listing API — pagination, filters, and N+1, builds the listing API. We add pagination from the start so it does not break as the row count grows. We also make the N+1 problem — the reason this chapter used whenLoaded — actually happen, and then kill it.
Exercises
The following Resource has two things that cause problems in production. Point them out
class ProductResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'price' => '¥' . number_format($this->price),
'stock_quantity' => Stock::where('product_id', $this->id)->value('quantity'),
];
}
}
Sample answer
First: the amount is returned as a pre-formatted string.
A client receiving "¥12,800" cannot use it for calculation. Producing a total means stripping the symbol and the separators from the string and converting back to a number, with no guarantee that the processing is correct. Separator placement varies by language, and so does the currency symbol.
Return the integer and leave display formatting to the client.
Second: a query is issued inside the Resource.
Stock::where(...) fires one SELECT every time this Resource is converted. Return 50 records in a product listing and that is 50 queries.
Worse, this query is invisible from the controller. When someone reports "the listing API is slow," reading the controller and the service class turns up nothing; you only find it once you get as far as the Resource.
If stock is needed, load it first on the controller side with something like Product::with('stock')->paginate(20) and reference it in the Resource with $this->whenLoaded('stock').
A request comes in to change customer_email in a published API's response to a nested structure like customer.email. How do you respond?
Sample answer
You cannot change it as is. A client reading response.data.customer_email starts receiving undefined. Nothing errors; a blank simply appears on screen, so discovery is slow.
There are three options.
Return both: keep customer_email and add a customer object. Additions break nothing, so this can be done immediately. Set a migration window, and once clients have switched to customer.email, remove customer_email. Removing it needs an announcement.
Bump the version: create /api/v2 and reorganize the structure. When the change is not confined to one place but spans the whole response, this ends up cheaper. It does cost you maintaining both v1 and v2.
Do not change it: check the reasoning behind the request. If it is "we want the hierarchy when customer information grows," that is a future concern and can wait until it actually grows. If it is only "it looks tidier," it is not worth breaking clients over.
The axis for the decision is the same as chapter 4's. Adding does not break; removing and renaming do. Because of that asymmetry, holding to "when in doubt, leave it out" in the initial design widens the options you have later.