skip to content

In a Laravel API resource, how do when() and mergeWhen() include fields only for some viewers, and what removes the key otherwise?

level: middleimportance: should knowfreq 42%

answer

  1. a sentinel object, not null
  2. MissingValue stripped by filter()
  3. closure defers the expensive value
  4. third argument keeps the key
  5. mergeWhen splices several keys at once

basics

~10 s

when($condition, $value) returns the value when the condition is true and a MissingValue otherwise; mergeWhen($condition, [...]) does the same for several keys. Laravel strips every MissingValue before encoding, so the keys vanish.

solid answer

~40 s

Inside `toArray()` you write `'resting_hr' => $this->when($isOwner, fn () => $this->restingHeartRate())`. If the condition is truthy the value (or the closure's result) is used; if not, `when()` returns a `MissingValue` sentinel, and the resource's `filter()` step removes that key before encoding, so the field is absent rather than `null`. Passing a closure matters: a plain expression is evaluated before `when()` is even called. A third argument, `when($cond, $value, $default)`, keeps the key with the default instead. `mergeWhen($isCoach, ['coach_notes' => ..., 'rpe' => ...])` returns a `MergeValue` that splices several keys into the surrounding array under one condition. Related helpers: `unless()`, `mergeUnless()`, `whenHas()` for attributes actually selected, and `whenNotNull()`.

code

php · 17 lines
php
<?php

use Illuminate\Http\Request;

// Inside WorkoutResource
public function toArray(Request $request): array
{
    $isOwner = $request->user()?->id === $this->user_id;

    return [
        'id' => $this->id,
        // key absent for everyone but the owner; query runs only for the owner
        'avg_hr' => $this->when($isOwner, fn () => $this->heartRateSamples()->avg('bpm')),
        // key always present: 'private' for non-owners
        'visibility' => $this->when($isOwner, $this->visibility, 'private'),
    ];
}

go deeper

for a junior

Recall that when() drops the key when the condition is false and mergeWhen() adds a group of keys under one condition.

for a middle

Explain the MissingValue sentinel and the filter() step, why a closure defers work, how the third argument keeps the key, and how MergeValue splices keys at its position.

for a senior

Spot the hidden cost of non-closure values across a collection, keep audience rules cheap per item, and make sure authorization happens before the resource rather than inside it.

for a principal

Decide when audience-specific output should stay in one conditional resource and when separate resources per audience are clearer to review, test and version.

## The need: one resource, several audiences An API resource is a class extending `JsonResource` whose `toArray(Request $request)` returns the JSON for one model. In a fitness-tracker API the same `WorkoutResource` may be seen by the athlete who logged the workout, by their coach, and by friends in a feed. The athlete sees heart-rate data; the coach also sees private coaching notes; friends see neither. Building the array with `if` blocks and `unset()` works but scatters the shape across branches. Laravel's **conditional attribute** helpers keep the array declarative: every key is written once, next to its condition. ## How when() works `$this->when($condition, $value, $default)`: 1. If `$condition` is truthy, it returns `$value`; when `$value` is a closure, the closure is called and its result is used. 2. If `$condition` is falsy and no third argument was passed, it returns a **`MissingValue`** object, a sentinel that implements `PotentiallyMissing`. 3. If a third argument was passed, it returns that default instead, so the key stays. When the resource is resolved, its protected `filter()` method walks the array and **removes every `MissingValue`**. The client therefore sees the key disappear, not a `null`. That distinction matters for mobile clients: `null` says the value is unknown, while an absent key says it is not part of this response. `unless($condition, $value)` is the inverse. `whenNotNull($value)` includes a value only when it is not null, and `whenHas('vo2max')` includes an attribute only when it is present on the model, which is useful when a query used `select()` to fetch a subset of columns. ## Evaluation order: pass a closure PHP evaluates arguments before calling a method. In ```php 'avg_hr' => $this->when($isOwner, $this->heartRateSamples()->avg('bpm')), ``` the average query runs for every viewer; only the key is dropped for non-owners. Writing `fn () => $this->heartRateSamples()->avg('bpm')` defers the work until the condition is known to be true. In a collection of 50 workouts that is the difference between 50 queries and none for a friend's feed. The condition itself is always evaluated, per item, so keep it cheap. ## mergeWhen(): several keys, one condition `$this->mergeWhen($isCoach, [...])` returns a `MergeValue` when the condition is true (or a `MissingValue` when false). You place it in the array **without a key**. During `filter()`, a `MergeValue` at a numeric position is spliced into the parent array at that position, so its keys become ordinary top-level keys of the resource. | Helper | Returns when true | Returns when false | Placed as | |---|---|---|---| | `when($c, $v)` | `$v` or the closure result | `MissingValue` | a keyed entry | | `when($c, $v, $d)` | `$v` | `$d` (key kept) | a keyed entry | | `unless($c, $v)` | `MissingValue` | `$v` | a keyed entry | | `mergeWhen($c, [...])` | `MergeValue` of the array | `MissingValue` | an unkeyed entry | | `whenHas('col')` | the attribute | `MissingValue` | a keyed entry | The Laravel documentation warns against using `mergeWhen` inside arrays that mix string and numeric keys, or whose numeric keys are not sequential, because the splice relies on positions. ## Example ```php <?php public function toArray(Request $request): array { $viewerId = $request->user()?->id; $isOwner = $viewerId === $this->user_id; $isCoach = $viewerId !== null && $viewerId === $this->coach_id; return [ 'id' => $this->id, 'type' => $this->type, 'distance_km' => round($this->distance_m / 1000, 2), 'avg_hr' => $this->when($isOwner || $isCoach, fn () => $this->avg_hr), $this->mergeWhen($isCoach, [ 'coach_notes' => $this->coach_notes, 'rpe' => $this->rpe, ]), ]; } ``` A friend gets `id`, `type` and `distance_km`; the athlete adds `avg_hr`; the coach adds `avg_hr`, `coach_notes` and `rpe` as flat top-level keys. ## Traps and limits - **Non-closure values are computed anyway**, so expensive work or lazy relation access still happens for hidden fields. - **Hiding is not authorization**: a conditional field controls output only. Whether the viewer may see the workout at all is decided before the resource runs. - **Reading `$request->user()` without the null-safe operator** breaks for guests on public endpoints. - **Expecting `null` for hidden fields** in the mobile client's decoder; the key is absent, so optional fields must be modelled as optional, not nullable. - **Using the third argument by accident**: `when($c, $v, null)` keeps the key with `null`, because the check is on the number of arguments, not on the default's value.

  • Why can when($cond, $value, null) produce a different response from when($cond, $value)?
    `when()` checks how many arguments it received, not the default's value. With two arguments a false condition returns a `MissingValue`, so the key is removed. With an explicit third argument, even `null`, it returns that default, so the key is kept with `null`. Choose deliberately: absent means not part of this response, null means no value.
  • When is whenHas() the right helper instead of when()?
    `whenHas('vo2max')` checks whether the attribute exists in the model's loaded attributes. It fits queries that used `select()` to fetch a subset of columns for a lightweight endpoint: reading a column that was not selected would normally give `null`, whereas `whenHas` drops the key. It says nothing about who the viewer is; use `when()` for audience rules.

A cinema ticket printer that simply skips the loyalty-points line for guests: the ticket is shorter, not printed with a blank line, and the points are only calculated if you hand the printer a recipe instead of a precomputed number.

saying these in an interview costs you the question

  • A false when() condition serializes the field as null.
  • when() skips computing its second argument even when it is a plain expression.
  • mergeWhen() nests its fields under a separate child object.
  • Conditional fields are enough to authorize access to a record.
  • Passing null as when()'s default is the same as passing no default.