With Laravel Pennant, how does a Lottery-based 10% rollout stay stable per user, and how do the database and array drivers change that?
answer
- the lottery is drawn once, then stored
- features table: name, scope, value
- unique (name, scope)
- App\Models\User|42 as the scope key
- array driver: in memory only
basics
~20 sPennant draws the lottery on a user's first check and stores the result in the features table by feature name and scope, so later checks reuse it. The array driver keeps results in memory only, so each request draws again.
solid answer
~40 s`Lottery::odds(1, 10)` is random; Pennant makes it stable by **persisting the first result**. With the default `database` store (`PENNANT_STORE`, default `database`), the first check inserts a row into `features` with the feature `name`, the serialized `scope` (by default the model class and key, like `App\Models\User|42`) and the JSON `value`, protected by a unique index on `name` and `scope`. Later checks read that row, and within a request an in-memory cache avoids repeat queries. The `array` store keeps resolved values in a PHP array that is flushed with the request, so a lottery is re-drawn every request and a user flips between checkouts: fine for tests, wrong for a real rollout. Checking a feature that was never defined returns `false` and stores nothing.
code
ini · 1 linePENNANT_STORE=databasego deeper
Recall that Pennant remembers each user's first result, and that the database store is the default while the array store keeps values only in memory.
Explain the features table columns, how scopes are serialized, the unique index and retry on concurrent inserts, and the per-request in-memory cache.
Predict the effect of changing a definition, renaming a model or feature class, or switching stores on a live rollout, and plan purges accordingly.
Decide when Pennant's store-the-first-draw model fits and when a rollout needs deterministic bucketing or an external flag service instead.
## The rollout problem Pennant solves For the reserved scenario, a new checkout shown to 10% of users, the definition returns `Lottery::odds(1, 10)` for ordinary users. A lottery is **random**: evaluated twice for the same user, it can give different answers. A user who sees the new checkout on the cart page and the old one on the payment page is a bug. Pennant does not solve this by hashing the user ID. It solves it by **drawing once and remembering**: 1. on the first check for a feature and scope, Pennant calls the resolver and, if it returned a `Lottery`, draws it; 2. the store saves that result for that feature and scope; 3. every later check reads the saved result and never calls the resolver again. ## The database store `config/pennant.php` sets `'default' => env('PENNANT_STORE', 'database')` and defines two stores, `array` and `database`. The `database` store writes to the `features` table created by Pennant's migration: | Column | Holds | |---|---| | `name` | the feature name, or the class name or `#[Name]` value for class features | | `scope` | the serialized scope | | `value` | the resolved value, JSON-encoded, so rich values survive | | `created_at`, `updated_at` | timestamps | A **unique index on `name` and `scope`** guarantees one stored answer per user. If two requests for the same user resolve at the same moment, one insert hits the unique constraint; the database driver catches `UniqueConstraintViolationException` and re-reads the winner's row instead of storing a second answer. ## How the scope becomes a key `Feature::serializeScope()` turns a scope into the `scope` string: - an Eloquent model becomes its class and key, `App\Models\User|42`, or its morph alias and key after `Feature::useMorphMap()`; - strings and numbers are stored as they are; - `null` becomes the placeholder `__laravel_null`; - any other object must implement `FeatureScopeSerializeable`, or Pennant throws. Because the model's class is part of the key, moving `User` to another namespace without a morph map strands stored values, just like renaming a class-based feature. ## The request cache On top of either store, Pennant keeps an **in-memory cache** for the current request. Re-checking `new-checkout` in a controller, a view and a policy costs one query at most, and the answer cannot change mid-request. Pennant flushes this cache at the start of each Octane request and after each processed queue job, and `Feature::flushCache()` flushes it by hand. ## The array store The `array` driver keeps resolved values in a PHP array inside the driver. Nothing is written to a database, and the values are flushed with the in-memory cache, so a new request resolves every feature again. Consequences: - a `Lottery` is re-drawn on each request, so a 10% rollout becomes a coin that is flipped again for every page view; - `activate()` for a user lasts only until the request ends; - it is fast and needs no table, which suits tests and features whose resolver is deterministic, such as `fn (User $u) => $u->is_staff`. ## Unknown features Checking a name that was never defined, for example after a typo, makes the driver dispatch `UnknownFeatureResolved` and return `false`, and the database driver stores nothing for it. The flag silently reads as off, so listen for that event in development if typos are a concern. ## Comparing the stores | Question | `database` | `array` | |---|---|---| | Lottery result stable across requests? | yes | no | | Needs a migration? | yes, `features` | no | | Survives a deploy? | yes | no | | Typical use | real rollouts | tests, deterministic flags | ## Interview traps - **"Changing the odds changes who is in."** Only for scopes without a stored row; everyone else keeps their first draw until you purge. - **"The array store is a faster database store."** It is a different contract: nothing survives the request. - **"Stability comes from a hash, so it survives a purge."** Pennant stores draws rather than hashing, so a purge re-draws and reshuffles the rollout.
- In Laravel Pennant, you change the new-checkout lottery from 1 in 10 to 1 in 2. Why do most existing users see no change?Their first result is already stored in `features`, and Pennant never re-runs the resolver for a stored scope. Only users checked for the first time get the new odds. To re-draw for everyone, purge the stored values with `Feature::purge('new-checkout')` or `pennant:purge new-checkout`, accepting that some users will switch checkout.
- Why might two app servers disagree about Laravel Pennant's value for the same user when the array store is configured?The array store lives in each PHP process's memory and is flushed per request, so each request draws the lottery independently. Nothing is shared between servers or requests; only the database store, or a custom shared driver, gives a single stored answer.
saying these in an interview costs you the question
- Pennant hashes the user ID to decide who falls into the 10%.
- The array driver persists values between requests like a cache.
- Editing the lottery odds immediately re-buckets every existing user.
- Checking an undefined feature throws an exception.
- Two concurrent first checks can store two different values for one user.