skip to content

Checking a Laravel Pennant flag inside a loop over 500 users fires hundreds of queries and returns false in queued jobs. Why, and how do you fix both?

level: seniorimportance: should knowfreq 20%

answer

  1. one lookup per scope, the flag N+1
  2. Feature::for($users)->loadMissing([...])
  3. null default scope outside HTTP
  4. UnexpectedNullScopeEncountered, then false
  5. resolveScopeUsing() and globally()

basics

~20 s

Each Feature::for($user)->active() looks up one scope, so a loop queries once per user; Feature::for($users)->loadMissing() fetches them in bulk first. Queued jobs have no authenticated user, so the null default scope makes a User-typed resolver resolve false.

solid answer

~40 s

Pennant's in-memory cache only helps repeat checks of the same scope. In `foreach ($users as $user) Feature::for($user)->active('new-checkout')`, every user is a new scope, so the database store runs a query per user, plus an insert for each user never resolved. Call `Feature::for($users)->loadMissing(['new-checkout'])` before the loop: it fetches the stored rows for all those scopes in one go and resolves and inserts the missing ones together. The job problem is the **default scope**: it is the authenticated user, and a queued job or Artisan command has none, so the scope is `null`; a resolver typed `User $user` cannot take it, so Pennant dispatches `UnexpectedNullScopeEncountered` and returns `false`. Pass `Feature::for($this->user)` in jobs, type the resolver `?User` if guests matter, or use `resolveScopeUsing()` for a non-user default; `Feature::globally()`, new in Pennant 1.25, checks app-wide flags.

code

php · 14 lines
php
<?php

use Laravel\Pennant\Feature;

// In a queued job: load once, then check from memory
Feature::for($users)->loadMissing(['new-checkout']);

foreach ($users as $user) {
    $link = Feature::for($user)->active('new-checkout')
        ? route('checkout.v2')
        : route('checkout');

    // send the reminder with $link...
}

go deeper

for a junior

Recall that Feature::active() uses the logged-in user, so outside a web request you pass the user with Feature::for().

for a middle

Explain how load() and loadMissing() remove per-scope queries, and how nullable resolver types decide what happens with a null scope.

for a senior

Diagnose flags that read false in jobs and query storms in loops, and choose user, team or global scope deliberately for each feature.

for a principal

Set conventions for scope types and flag checks in background work so rollouts behave the same in web, jobs and commands.

## Two symptoms, one root: the scope Every Pennant check answers 'what is this feature for this scope?'. Both production surprises here come from what the scope is and how many of them you touch. ## The flag N+1 A nightly job sends checkout reminders and wants the new checkout's link only for users in the rollout: - `foreach ($users as $user) { if (Feature::for($user)->active('new-checkout')) { ... } }` Pennant's **in-memory cache** stores answers per feature and scope for the request or job, so it cannot help the first check for each of 500 different users. With the `database` store each iteration does one `SELECT` on `features`, and a user never resolved before costs an `INSERT` too: potentially a thousand queries. This is the same shape as an Eloquent N+1 and has the same cure, **eager loading**: 1. `Feature::for($users)->load(['new-checkout'])` fetches stored values for every user in one query, resolves the missing ones, inserts them together, and fills the cache; 2. `Feature::for($users)->loadMissing([...])` skips scopes already in the cache, so it is safe to call repeatedly; 3. `Feature::for($users)->loadAll()` loads every defined feature for those scopes. After loading, the loop's `active()` calls are served from memory. `EnsureFeaturesAreActive` and Pennant's `values()` call `loadMissing()` internally for the same reason. ## The null scope outside HTTP Without `for()`, Pennant uses the **default scope**, `auth()->guard()->user()`. In a queued job, an Artisan command, a scheduled task or a guest request, there is no authenticated user, so the scope is `null`. Pennant then checks the resolver's first parameter: | Resolver signature | Result with a null scope | |---|---| | `fn (User $user)` | resolver skipped, `UnexpectedNullScopeEncountered` dispatched, value `false` | | `fn (?User $user)` or `fn (User\|null $user)` | resolver called with `null` | | `fn ($scope)` or `fn ()` | resolver called | So a job calling `Feature::active('new-checkout')` quietly gets `false` for everyone, even users who see the new checkout on the website. Fixes, in order of preference: 1. pass the scope explicitly in jobs and commands: `Feature::for($this->user)->active('new-checkout')`; 2. make the resolver nullable if guests should be decided, and decide what `null` means; 3. set a default that works outside HTTP with `Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team)`. A null scope is stored under the placeholder `__laravel_null`, so a nullable resolver's answer for guests is shared by all of them. ## Choosing the scope on purpose The scope decides who shares an answer: - **user**: each person decided separately, the default; - **team** or **organisation**: `Feature::for($user->team)` keeps colleagues on the same checkout, which matters when they collaborate on one order; - **global**: `Feature::globally()->active('maintenance-banner')` checks one app-wide value, whatever the default resolver says. `globally()` arrived in Pennant 1.25.0, so installs older than that do not have it. Mixing scopes for one feature, checking by user in one place and by team in another, creates two sets of stored rows that can disagree, so pick one per feature and write it into the definition's parameter type. ## Checklist - Loops over scopes: `loadMissing()` first. - Jobs and commands: always `for()` an explicit scope. - Definitions: type the parameter to match the scope you intend, nullable only on purpose. - App-wide switches: `globally()`. ## Interview traps - **"The per-request cache solves loops."** It only helps the same scope twice; different users each cost a lookup until you load them. - **"The job sees the dispatching user."** Queued jobs run later in a worker with no authenticated user; pass the model into the job and use `for()`. - **"A null scope throws."** Pennant resolves the flag as `false` and dispatches an event instead, which is quieter and easier to miss.

  • Does Laravel Pennant's in-memory cache carry over between queued jobs processed by the same worker?
    No. Pennant listens for `JobProcessed` and flushes its cache after each job, and it also flushes at the start of each Octane request. Each job therefore starts cold, which is why loading scopes up front inside the job matters.
  • How would you roll out Laravel Pennant's new-checkout flag per team instead of per user?
    Type the resolver's parameter as `Team $team` and check with `Feature::for($user->team)`, or set `Feature::resolveScopeUsing(fn ($driver) => Auth::user()?->team)` so plain `Feature::active()` uses the team. Everyone on a team then shares one stored answer.

saying these in an interview costs you the question

  • Pennant's in-memory cache prevents per-user queries in a loop.
  • Queued jobs inherit the authenticated user of the request that dispatched them.
  • A null scope with a User-typed resolver throws a TypeError.
  • Feature::for($users)->load() only warms the cache and never resolves missing values.
  • Checking one feature by user in one place and by team in another stays consistent.