feat(subscriptions): pin the winning variant via env

Add SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT: a Pennant before() override that
serves one variant to every user, so once a winner is chosen the split ends
with an env flip (no deploy, stored assignments untouched). Invalid values are
ignored and fall through to normal assignment.
This commit is contained in:
Víctor Falcón 2026-06-27 16:29:55 +02:00
parent f1d1fb0f71
commit 051ef165f6
3 changed files with 40 additions and 3 deletions

View File

@ -13,9 +13,8 @@ use Carbon\CarbonImmutable;
* registered on or after the start is split evenly into the three variants by a
* stable hash of their id, so the bucket never changes for a given user.
*
* The split is deterministic (crc32(id) % 3); the funnel report mirrors the same
* formula in SQL. Manual overrides via `feature:enable` are honoured at runtime
* but are not reflected in that report keep overrides to QA accounts.
* The split is deterministic (crc32(id) % 3) and persisted by Pennant; the funnel
* report reads that persisted value so it always matches what the user was served.
*
* @api
*/
@ -29,6 +28,21 @@ class SubscriptionExperiment
public const PAY_NOW = 'pay_now';
/**
* In-memory override that pins every user to the winning variant once the
* experiment is decided. Returning non-null skips both storage and resolve,
* so flipping SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT rolls the winner out to
* everyone without a deploy and without rewriting stored assignments.
*/
public function before(?User $user): ?string
{
$forced = config('subscriptions.experiment.force_variant');
return in_array($forced, [self::CONTROL, self::REDUCED_TRIAL, self::PAY_NOW], true)
? $forced
: null;
}
public function resolve(?User $user): string
{
$startedAt = config('subscriptions.experiment.started_at');

View File

@ -35,6 +35,9 @@ return [
'experiment' => [
'started_at' => env('SUBSCRIPTION_EXPERIMENT_STARTED_AT'),
// Once a winner is chosen, set this to control / reduced_trial / pay_now
// to give every user that variant and end the split (env-only, no deploy).
'force_variant' => env('SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT'),
'reduced_trial' => [
'monthly' => (int) env('SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_MONTHLY', 3),
'yearly' => (int) env('SUBSCRIPTION_EXPERIMENT_REDUCED_TRIAL_YEARLY', 7),

View File

@ -36,6 +36,26 @@ it('treats everyone as legacy while the experiment is off', function () {
expect(app(ExperimentOffer::class)->variantFor($user))->toBe(SubscriptionExperiment::LEGACY);
});
it('pins every user to the forced winner variant', function () {
config(['subscriptions.experiment.force_variant' => SubscriptionExperiment::PAY_NOW]);
$offer = app(ExperimentOffer::class);
$legacy = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-05-01')]);
$fresh = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-06-10')]);
expect($offer->variantFor($legacy))->toBe(SubscriptionExperiment::PAY_NOW)
->and($offer->variantFor($fresh))->toBe(SubscriptionExperiment::PAY_NOW)
->and($offer->trialDaysFor($fresh, 'monthly'))->toBe(0);
});
it('ignores an invalid forced variant', function () {
config(['subscriptions.experiment.force_variant' => 'bogus']);
$user = User::factory()->create(['created_at' => CarbonImmutable::parse('2026-05-01')]);
expect(app(ExperimentOffer::class)->variantFor($user))->toBe(SubscriptionExperiment::LEGACY);
});
it('splits post-start users across all three variants and stays stable per user', function () {
$offer = app(ExperimentOffer::class);
$variants = [];