The index kept the staff note off the list query. The model still has attributes the API should not repeat: the team id you already scoped, and a staff note. A resource is the list of fields a client is allowed to see. A new column stays private until someone adds it to that list on purpose.
Eloquent, properly · Lesson 3
API resources without leaking attributes
Video
01 Map the invoice, then its lines
whenLoaded() means the lines are included when the controller eager-loaded them, and omitted when it did not. That keeps a list endpoint small and a show endpoint complete. The status cast from earlier in the series means status is an enum here, so the resource sends the backed string with value. issued_on is a Carbon date, and toDateString() sends the calendar day rather than a timestamp the client did not ask for.
class InvoiceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'number' => $this->number,
'issued_on' => $this->issued_on->toDateString(),
'status' => $this->status->value,
'lines' => InvoiceLineResource::collection($this->whenLoaded('lines')),
];
}
}The controller chooses the shape by choosing the load. The list eager-loads lines only when the client of this endpoint is supposed to see them. A list that is numbers and statuses omits with('lines'), and whenLoaded() then omits the key. MissingAttributeException and the lazy-load guard never enter into it, because the resource does not touch a relation that was not loaded.
return InvoiceResource::collection(
Invoice::query()->forTeam($team)->with('lines')->paginate(20)
);Do not return $invoice->toArray() from an API. It will grow every time someone adds a column, and the new column is public the moment the migration runs. $hidden on the model is easy to bypass, because a different path can call toArray() or return the model from a job payload. The resource returned by this controller is the list you can review. team_id is absent on purpose. The client already asked in the context of a team, and repeating the key invites a client to trust it as an authorisation check.
02 Write the line resource, and do not touch lines directly
InvoiceLineResource is the same kind of list, one level down. description and amount are the fields a client needs to render a line. amount stays an integer of pence. The resource does not divide by 100, so every client shares the same unit the database stores. A display rule belongs in one formatter, not in a second copy of the resource that silently changes the number.
class InvoiceLineResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'description' => $this->description,
'amount' => $this->amount,
];
}
}The trap is reading $this->lines instead of $this->whenLoaded('lines'). The property lazy-loads. On the list, that is one query per invoice, and outside production the guard from the previous lesson throws LazyLoadingViolationException. whenLoaded() is how the resource refuses that query. If the show endpoint must include lines, the show action calls loadMissing('lines') or with('lines') before it builds the resource. The resource stays honest either way.
A nested team resource has the same rule. Do not embed the whole team so the client can see a name, unless this response eager-loaded team and a TeamResource lists the fields that are safe. Stuffing $this->team->toArray() into the invoice publishes every team column. Leave notes and foreign keys off both resources until a client has a reason to see them.
Recap
You can decide the invoice fields a client may see, map each line through its own resource, and keep a missing eager load from becoming a lazy query inside toArray().
toArray()on the model publishes every attribute you add later.whenLoaded('lines')keeps a list endpoint small.- The controller chooses the shape by choosing the eager load.
InvoiceLineResourcelistsdescriptionandamount. Reading$this->lineslazy-loads the relation the guard is there to catch.
Check yourself · 3 questions
Question 1 of 3Someone adds payment_attempt to the invoices table. The API returns $invoice->toArray(). Who can see the new column?
Where the series leaves you
The invoice now has a path through the series.
- The model and the fillable list.
- The constrained foreign keys.
- The relationships, and the forTeam() scope.
- The two-query list behind the InvoiceResource.
Test-driven Laravel is how you prove all of it.