Pennant rollback: stored values outlive your resolver

A real SQLite fixture shows how stored decisions and local caches behave during a rollback, including the unseen scopes an old resolver can still enable.
A new definition does not overwrite an existing decision
An account checks a feature and stores true. You change its resolver to return false, clear Pennant's local cache, and check that account again. It still gets true. An account with no stored decision gets false. Both results follow from the same database-backed feature definition.
This walkthrough is for an engineer planning a rollout or rollback with Pennant. The fixture uses Pennant 1.26.0, Laravel 12.69.3 and PHP 8.5.6 with the package's actual migration and an SQLite file. The scopes are synthetic account strings. They are not authenticated users, and a flag value establishes no permission to read another account's data.
SQLite fits this fixture because it exercises the package migration and persisted lookups in a disposable file without a database server. It does not reproduce the locking or concurrent-write behavior of your production database; run those checks against the database your application uses.
A resolver supplies a missing decision
The first lookup for account:alice invokes a resolver returning true. The next stage registers a false resolver and flushes the local cache. Alice still gets true and the resolver call count remains unchanged. Looking up account:bob calls the new resolver and returns false.
The pinned DatabaseDriver reads an existing row before calling the resolver. Changing the definition changes how missing values resolve. It does not rewrite values already present in the features table. This distinction matters if a resolver contains eligibility logic that changes over time: a stored rollout decision is not evidence of current entitlement.
Update existing rows and the default separately
The published helper demonstrates two operations on the current manager. It registers a false default, then calls deactivateForEveryone for the feature. The test flushes that manager's cache before checking results, so its assertions must read the stored state rather than succeed on a warmed value.
After the helper runs, Alice's existing decision is false, Bob remains false, and the unseen account:charlie also gets false. The table contains three false values for that feature. A separate feature for Alice remains enabled, showing that the update is scoped to the named feature.
The helper is a local mechanism demonstration, not a production kill-switch command. Registering a closure in one manager does not deploy that definition to another worker or to future application boots. The fixture deliberately checks that limitation rather than describing a successful local update as a completed production rollback.
<?php
declare(strict_types=1);
use Laravel\Pennant\FeatureManager;
function disableSummary(FeatureManager $features): void
{
$features->define('account-summary-v2', static fn (string $scope): bool => false);
$features->deactivateForEveryone('account-summary-v2');
}
Another manager can disagree
A second manager reads Alice before the update and keeps the true result in its local cache. After the first manager updates the database, that second manager still returns true. Flushing its cache makes Alice's next lookup return false.
That manager also retains its original true resolver. When it sees the previously unseen account:david, it resolves and stores true. Clearing cached values alone therefore does not repair an outdated definition. These are two manager instances in one PHP process, not measurements of a distributed deployment or a claim about how quickly all workers observe an update.
I recommend treating the safe definition as an application deployment change, and the stored-value update as a separate operational step. Verify how long requests and workers can retain old state before choosing an ordering. This sequential fixture proves neither an atomic rollout nor a safe ordering for your application's in-flight work.
Purging is a request to resolve again
A separate purge-demo feature starts with a true resolver. Bulk deactivation makes its stored decision false. Purging removes that row. The next lookup runs the still-true resolver and creates a new true value.
That is useful when intentionally discarding an old decision. It is unsuitable as an unconditional disable operation while an enabling resolver remains active. The probe checks the row count before and after the final lookup, so the result cannot be mistaken for merely clearing an in-memory cache.
Make the tests detect an incomplete rollback
Two altered copies of the helper run through the same probe. Removing the stored update leaves Alice enabled even after the acting manager's cache is flushed. Removing the false default disables existing rows but enables the unseen Charlie scope. Each variant must fail its named assertion with the expected preceding observations. An unrelated exception does not count as successful evidence.
Download and extract the source bundle, then run python3 verify.py with Docker available. The first build downloads the pinned runtime and locked dependencies. Expected output is JSON with status pass and three results: the original probe exits 0, and each deliberately broken variant exits 1. The verifier itself exits 0 only when all expected outcomes match.
The normal run checks 21 observations. Execution has no external networking, runs as an unprivileged user with a read-only root, and has bounded memory, temporary storage, captured output and elapsed time. The source bundle includes a dated dependency audit: on 13 October 2026 it reported no advisories or abandoned packages for this lock. That result does not certify the application or future dependency state.
The fixture does not test HTTP authentication, concurrent database writes, long-running queue workers or the compatibility of data written by old and new implementations. Those remain application tests. A flag changing which branch runs cannot establish that rolling back that branch will preserve its data.
Reproduce the examples
Exact local rollback helper, SQLite probe using the package migration, two deliberate defects, pinned dependency lock, bounded runner and dated advisory report. Requires Docker and Python 3. This is a sequential two-manager fixture, not a distributed kill switch.
Download the source and tests (.zip)
