skip to content

In a Laravel API resource, what do whenLoaded() and whenCounted() do, and why use them instead of reading the relation directly?

level: middleimportance: must knowfreq 55%

answer

  1. the controller decides, the resource reflects
  2. pass the relation name, not the value
  3. relationLoaded() check, never a query
  4. MissingValue removes the key
  5. whenCounted reads the snake_case _count attribute

basics

~10 s

whenLoaded('exercises') includes a relation only if it was already eager-loaded, and whenCounted('exercises') includes exercises_count only if withCount or loadCount set it; otherwise the key is dropped and no query runs.

solid answer

~30 s

Inside `toArray()`, `ExerciseResource::collection($this->whenLoaded('exercises'))` checks `relationLoaded('exercises')` on the model. If the relation is loaded it is used; if not, a `MissingValue` is returned and Laravel removes the key before encoding. `whenCounted('exercises')` does the same for the `exercises_count` attribute that `withCount()` or `loadCount()` adds. Writing `$this->exercises` instead would lazy-load the relation for every workout, turning a 50-item list into 51 queries. With the conditional helpers the controller chooses what to load per endpoint (the list loads nothing, the detail page loads exercises) and one resource class serves both without extra queries.

code

php · 23 lines
php
<?php

namespace App\Http\Controllers;

use App\Http\Resources\WorkoutResource;
use App\Models\Workout;

class WorkoutController
{
    public function index()
    {
        // exercises_count present, exercises and coach keys omitted
        return WorkoutResource::collection(
            Workout::withCount('exercises')->latest()->paginate(20)
        );
    }

    public function show(Workout $workout): WorkoutResource
    {
        // exercises and coach present, exercises_count omitted
        return new WorkoutResource($workout->load(['exercises', 'coach']));
    }
}

go deeper

for a junior

Remember that whenLoaded takes the relation name as a string and whenCounted pairs with withCount; both drop the key when the data was not loaded.

for a middle

Explain the relationLoaded() check, the MissingValue that filter() removes, the snake_case _count lookup, and why a loaded-but-null belongsTo keeps its key with null.

for a senior

Show how one resource serves list and detail endpoints without N+1, how you would catch a stray direct relation access, and how you document which keys each endpoint includes.

for a principal

Weigh controller-chosen includes against a client-driven include parameter for a public API: query cost control, cacheability and how clients learn which keys to expect.

## The problem the helpers solve An API resource is a class extending `JsonResource` whose `toArray()` describes the JSON for one model. Resources are reused: the same `WorkoutResource` renders a single workout on `GET /workouts/{id}` and every item on `GET /workouts`. If `toArray()` reads a relation directly, for example `'exercises' => ExerciseResource::collection($this->exercises)`, Eloquent **lazy-loads** it on first access. For one workout that is one extra query; for a page of 50 workouts it is 50 extra queries, the classic N+1 pattern. It also forces every endpoint to return exercises, even the list screen of the mobile app that only needs totals. The conditional-relationship helpers move the decision to the controller. The controller eager-loads what a given endpoint needs; the resource includes a relation **only if it is already there**. ## whenLoaded() `$this->whenLoaded('exercises')` takes the **name** of the relation as a string. Internally it calls the model's `relationLoaded('exercises')`, which only checks whether that key exists in the model's loaded-relations array. That check never touches the database. - **Not loaded**: it returns a `MissingValue` object (or your third argument, if you pass a default). When the resource is resolved, `filter()` removes every `MissingValue`, so the `exercises` key simply disappears from the JSON. - **Loaded**: it returns the loaded value. Wrapped in `ExerciseResource::collection(...)`, each exercise is rendered through its own resource. - **Loaded but null** (a `belongsTo` such as `coach` with no row): with one argument it returns `null`; wrapped as `new CoachResource(...)` the key is kept with the value `null`. With a second-argument closure it returns `null` without calling the closure. The string argument is the whole point. `$this->whenLoaded($this->exercises)` would evaluate `$this->exercises` first, triggering the very lazy load the helper exists to avoid, and then pass a collection where a name is expected. The check is literal: `relationLoaded()` looks up the exact key, so a dotted name such as `'exercises.sets'` is not recognised. Nested relations are handled by calling `whenLoaded('sets')` inside `ExerciseResource`. ## Why the key disappears instead of being null The helpers return a `MissingValue`, a tiny class implementing the `PotentiallyMissing` interface whose `isMissing()` returns true. After `toArray()` runs, the resource's `resolve()` calls `filter()`, which walks the array, recursing into nested arrays, and unsets every such value. It also unsets a nested resource that wraps a missing value, which is how `ExerciseResource::collection($this->whenLoaded('exercises'))` vanishes as a whole. Lists with numeric keys are re-indexed afterwards, so they still encode as JSON arrays. ## whenCounted() and whenAggregated() `withCount('exercises')` or `loadCount('exercises')` adds an attribute named `exercises_count` to each model. `$this->whenCounted('exercises')` converts the relation name to snake case and appends `_count`, so `whenCounted('personalRecords')` reads `personal_records_count`. If that attribute is not among the model's attributes, the key is dropped; if it is present, its value is used. `whenAggregated('sets', 'weight_kg', 'sum')` follows the same rule for attributes produced by `withSum`, `withAvg`, `withMin` and `withMax`. | Helper | Checks for | Filled by | Missing means | |---|---|---|---| | `whenLoaded('exercises')` | a loaded relation | `with()`, `load()` | key removed | | `whenCounted('exercises')` | `exercises_count` attribute | `withCount()`, `loadCount()` | key removed | | `whenAggregated('sets', 'weight_kg', 'sum')` | the aggregate attribute | `withSum()` and siblings | key removed | ## Putting it together ```php <?php // WorkoutResource::toArray() return [ 'id' => $this->id, 'type' => $this->type, 'exercises' => ExerciseResource::collection($this->whenLoaded('exercises')), 'exercises_count' => $this->whenCounted('exercises'), 'coach' => new CoachResource($this->whenLoaded('coach')), ]; ``` The controllers then decide the shape per endpoint: 1. The list endpoint runs `Workout::withCount('exercises')->paginate(20)`: each item carries `exercises_count` but no `exercises` or `coach` keys, in one select with a count subquery, plus the paginator's total query. 2. The detail endpoint runs `$workout->load(['exercises', 'coach'])`: the item carries the full exercise list and the coach, and `exercises_count` disappears unless the count was loaded as well. ## Traps interviewers probe - **Passing the relation value** instead of its name, which lazy-loads before the check. - **Expecting `whenLoaded` to load the relation** when it is missing; it never does. - **Forgetting `withCount`** and wondering why `exercises_count` never appears. - **Mixing direct access and helpers**: one stray `$this->coach->name` in a list resource reintroduces N+1. Enabling lazy-loading prevention in development surfaces it as an exception rather than a slow page. - **Assuming an absent key means an empty list**: to a client, a missing `exercises` key means not requested, while `[]` means none exist. Document which endpoints include which keys.

  • Why does whenLoaded('exercises.sets') not include nested sets?
    `whenLoaded()` calls `relationLoaded()`, which checks the exact key in the model's loaded-relations array. A dotted path is not such a key, so the helper reports it missing. Eager-load `exercises.sets` in the controller and call `whenLoaded('sets')` inside `ExerciseResource`, so each level checks its own relation.
  • What does whenLoaded() return when the relation is loaded but empty or null?
    An empty has-many collection is a loaded value, so the key appears with `[]`. A loaded `belongsTo` with no related row is `null`: with one argument `whenLoaded` returns `null`, and `new CoachResource(null)` is serialized as `null`, so the key stays with a null value. Only an unloaded relation removes the key.
  • How do you include a relation's summed or averaged column only when it was queried?
    Use `whenAggregated('sets', 'weight_kg', 'sum')`. It looks for the attribute that `withSum('sets', 'weight_kg')` adds and returns it, or removes the key when absent. The same method covers `avg`, `min` and `max` aggregates added by the matching `with*` query methods.

saying these in an interview costs you the question

  • whenLoaded() loads the relation for you if it is missing.
  • Pass $this->exercises to whenLoaded() so it can inspect the collection.
  • An unloaded relation is serialized as an empty array.
  • whenCounted() runs a COUNT query when the count is not loaded.
  • Direct $this->relation access in a resource is fine because resources cache relations.