skip to content

In Laravel's query builder, how does when() apply an optional filter, and why can ->when($request->input('shop_id'), ...) skip the filter for shop 0?

level: middleimportance: should knowfreq 34%

answer

  1. truthiness, not presence
  2. closure gets query and value
  3. third argument runs when falsy
  4. "0" is falsy in PHP
  5. $request->filled() as condition

basics

~20 s

when($value, $callback) runs the callback only if $value is truthy, passing the builder and the value. The string "0" is falsy in PHP, so a shop_id of 0 skips the filter and returns every shop's rows.

solid answer

~40 s

`when()` comes from the `Conditionable` trait: it evaluates its first argument (calling it first if it is a closure) and, if **truthy**, calls `$callback($query, $value)`; otherwise it calls the optional third-argument closure, and either way returns the builder so the chain continues. Truthiness is PHP's: `null`, `''`, `'0'`, `0`, `false` and `[]` all skip the callback. A request value of `'0'` therefore drops the `shop_id` filter and the query returns rows for every shop. When the question is "was the input supplied", pass `$request->filled('shop_id')`, which treats `'0'` as filled, and read the value inside with `$request->integer('shop_id')`. `unless()` is the inverse. Verify with `toRawSql()`.

code

php · 16 lines
php
<?php

use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;

// Skips the filter when shop_id is "0"
$bad = DB::table('orders')
    ->when($request->input('shop_id'), fn (Builder $q, $id) => $q->where('shop_id', $id));

// Applies it whenever shop_id was supplied, including 0
$good = DB::table('orders')
    ->when($request->filled('shop_id'),
        fn (Builder $q) => $q->where('shop_id', $request->integer('shop_id')));

echo $good->toRawSql();
// select * from "orders" where "shop_id" = 0

go deeper

for a junior

Recall that when() runs its closure only for a truthy value and passes the builder and the value to it.

for a middle

Explain PHP truthiness in when(), why "0" skips a filter, and how filled() or an explicit comparison fixes it.

for a senior

Treat a skipped scoping filter as a data exposure, and verify composed queries with toRawSql() in tests for edge values such as 0.

for a principal

Decide whether optional filters are built ad hoc with when() or through a shared, validated filter object the whole team uses.

## The problem `when()` solves Admin screens often have optional filters: a shop, a status, a date range. Without help, the code fills with `if` blocks that reassign the builder. Laravel's query builder uses the **`Conditionable`** trait, which adds `when()` and `unless()` so the conditional part stays in the chain: ```php use Illuminate\Database\Query\Builder; $orders = DB::table('orders') ->when($request->input('status'), function (Builder $query, string $status) { $query->where('status', $status); }) ->latest() ->get(); ``` ## How `when()` decides The trait's logic is short: 1. If the first argument is a **closure**, it is called with the builder and its result becomes the value. 2. If the value is **truthy**, `$callback($this, $value)` runs. 3. Otherwise, if a **third argument** was given, that default callback runs with the same two arguments. 4. It returns the callback's return value, or the builder itself when the callback returns `null`, so the chain continues either way. The callback receives the value as its second argument, which is why the example can type-hint `string $status` instead of reading the request again. `unless()` is the mirror image: it runs the callback when the value is **falsy**. ## The `"0"` trap "Truthy" means PHP's own rules, not "present": | Value | Truthy? | Filter applied? | |---|---|---| | `'3'` | yes | yes | | `'0'` | **no** | **no** | | `''` | no | no | | `null` (key missing) | no | no | | `'false'` | yes | yes | So `->when($request->input('shop_id'), fn ($q, $id) => $q->where('shop_id', $id))` silently ignores `shop_id=0`. The query returns rows for every shop, which is a correctness bug and, when the filter is meant to restrict what a user can see, a data exposure. The same trap applies to a boolean-ish flag sent as `'0'`, and to an `is_archived` value of `0`. ## Safer conditions - **Test presence, not the value.** `$request->filled('shop_id')` returns `true` for `'0'`, because it only rejects empty strings, and arrays or booleans count as filled. Then read the value inside: `fn ($q) => $q->where('shop_id', $request->integer('shop_id'))`. - **Use `has()` when an empty string is still meaningful**; it checks only that the key exists. - **Compare explicitly** when the domain has its own "no filter" value: `->when($shopId !== null, ...)`. - **Validate first.** A validated integer from a form request removes the string ambiguity entirely. ## Using the default branch The third argument is useful for defaults that differ by condition, such as sorting: ```php ->when($request->boolean('by_total'), fn (Builder $q) => $q->orderByDesc('total_cents'), fn (Builder $q) => $q->latest()) ``` `$request->boolean()` turns `'1'`, `'true'`, `'on'` and `'yes'` into `true` and everything else into `false`, which makes the truthiness explicit. ## The short forms `when()` has two further shapes. Called with **one argument**, it returns a higher-order proxy, so `->when($request->filled('shop_id'))->where('shop_id', $request->integer('shop_id'))` applies the next method only when the condition holds. Called with a **closure as the condition**, it evaluates the closure against the builder first. Both obey the same truthiness rule, so the `"0"` trap applies to them exactly as it does to the two-argument form. ## Checking the result Because `when()` hides whether a clause was added, the fastest check is to look at the SQL: - `toSql()` returns the statement with `?` placeholders. - `toRawSql()` returns it with the bindings substituted, ready to paste into a database console. Neither executes the query. Use them in a test or a tinker session to confirm that `shop_id = 0` actually appears in the `where` clause. Treat the output of `toRawSql()` as a debugging aid; the query that runs still sends its values as bindings.

  • What is the difference between toSql() and toRawSql() on a Laravel query builder?
    `toSql()` compiles the statement and leaves `?` placeholders; the values are available from `getBindings()`. `toRawSql()` substitutes the bindings into the text, quoted for the connection's grammar, which is easier to paste into a database console. Neither runs the query, and neither changes how the real query is sent.
  • Can the when() callback return a value, and does it matter?
    Yes. `when()` returns whatever the callback returns, falling back to the builder when the callback returns `null`. An arrow function such as `fn ($q) => $q->where(...)` returns the builder, so the chain continues. A callback that returned something else, like a Collection from `get()`, would make the rest of the chain operate on that value.

saying these in an interview costs you the question

  • Believes when() checks whether the request key exists
  • Thinks the string "0" is truthy in PHP
  • Says when() returns null when the condition is false
  • Assumes toRawSql() runs the query to fill in values
  • Reads the request again inside every when() callback