refactor: explicit Pennant cache reset on plan_id change

Replaces the implicit Account::booted() observer with an explicit
Account::forgetPlanFeatureCache() method called from each plan_id
mutation site (StripeEventListener x3, BillingController x2). Self-hosted
installs naturally never reach any of these callsites — Stripe webhooks
do not fire and the billing controllers redirect to /calendar before any
plan mutation happens — so the Pennant flush is now guaranteed to be a
cloud-only operation.

Adds integration coverage proving the full chain webhook -> plan_id
update -> Pennant flush -> next Feature::value resolves against the new
plan limit.
This commit is contained in:
Paulo Castellano 2026-05-07 10:59:48 -03:00
parent b91bad7e6f
commit f72a97f676
5 changed files with 130 additions and 19 deletions

View file

@ -72,6 +72,10 @@ public function checkout(Request $request, Plan $plan): SymfonyResponse|Redirect
$account->update(['plan_id' => $plan->id]);
if ($account->wasChanged('plan_id')) {
$account->forgetPlanFeatureCache();
}
return Inertia::location($checkoutSession->url);
}
@ -164,6 +168,10 @@ public function swap(Request $request, Plan $plan): RedirectResponse
$subscription->swap($priceId);
$account->update(['plan_id' => $plan->id]);
if ($account->wasChanged('plan_id')) {
$account->forgetPlanFeatureCache();
}
return redirect()->route('app.billing.index')
->with('flash.success', __('billing.flash.plan_changed', ['plan' => $plan->name]));
}

View file

@ -53,6 +53,10 @@ protected function handleSubscriptionCreated(Account $account, array $payload):
if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) {
$account->update(['plan_id' => $plan->id]);
if ($account->wasChanged('plan_id')) {
$account->forgetPlanFeatureCache();
}
}
SubscriptionCreated::dispatch($account);
@ -64,8 +68,7 @@ protected function handleSubscriptionCreated(Account $account, array $payload):
* Plan changes coming from Stripe (billing portal, dashboard, or our own
* `BillingController@swap`) re-arrive here. Re-resolving the plan from
* the price ids guarantees the local state matches Stripe even when a
* change happens out-of-band. Pennant feature caches are forgotten
* automatically via Account::booted() when `plan_id` changes.
* change happens out-of-band.
*
* @param array<string, mixed> $payload
*/
@ -75,6 +78,10 @@ protected function handleSubscriptionUpdated(Account $account, array $payload):
if ($plan = $this->resolvePlanFromSubscriptionItems($payload, $account)) {
$account->update(['plan_id' => $plan->id]);
if ($account->wasChanged('plan_id')) {
$account->forgetPlanFeatureCache();
}
}
$this->trackPlanChange($account, BillingEvent::Updated, $previousPlan, $payload);
@ -94,6 +101,10 @@ protected function handleSubscriptionDeleted(Account $account, array $payload):
$account->update(['plan_id' => null]);
if ($account->wasChanged('plan_id')) {
$account->forgetPlanFeatureCache();
}
$this->trackPlanChange($account, BillingEvent::Cancelled, $previousPlan, $payload);
}

View file

@ -32,18 +32,21 @@ class Account extends Model
'plan_id',
];
protected static function booted(): void
/**
* Drop the cached Pennant feature values for the plan-scoped limits so
* the next read resolves against the current plan. Call this right after
* any `plan_id` change. Centralised here (instead of as a model hook) so
* self-hosted installs — where Stripe webhooks never fire and the billing
* controllers redirect away — never trigger this path.
*/
public function forgetPlanFeatureCache(): void
{
static::updated(function (Account $account): void {
if ($account->wasChanged('plan_id')) {
Feature::for($account)->forget([
WorkspaceLimit::class,
SocialAccountLimit::class,
MemberLimit::class,
MonthlyCreditsLimit::class,
]);
}
});
Feature::for($this)->forget([
WorkspaceLimit::class,
SocialAccountLimit::class,
MemberLimit::class,
MonthlyCreditsLimit::class,
]);
}
public function owner(): BelongsTo

View file

@ -4,15 +4,18 @@
use App\Enums\PostHog\BillingEvent;
use App\Events\SubscriptionCreated;
use App\Features\WorkspaceLimit;
use App\Jobs\PostHog\TrackBilling;
use App\Listeners\StripeEventListener;
use App\Models\Account;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
use Laravel\Pennant\Feature;
beforeEach(function () {
$this->account = Account::factory()->create(['stripe_id' => 'cus_test123']);
@ -394,3 +397,80 @@
fn ($job) => $job->event === BillingEvent::Created && $job->previousPlan === null,
);
});
// ========================================
// Pennant feature cache invalidation
// ========================================
test('subscription updated flushes the pennant cache when the plan changes', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$pro = Plan::query()->where('slug', 'pro')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
// Prime the Pennant cache against the starter limit.
expect(Feature::for($this->account)->value(WorkspaceLimit::class))
->toBe($starter->workspace_limit);
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
->toBeGreaterThan(0);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'customer' => 'cus_test123',
'items' => ['data' => [['price' => ['id' => 'price_pro_monthly']]]],
]],
]));
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
->toBe(0);
expect(Feature::for($this->account->fresh())->value(WorkspaceLimit::class))
->toBe($pro->workspace_limit);
});
test('subscription deleted flushes the pennant cache', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
Feature::for($this->account)->value(WorkspaceLimit::class);
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
->toBeGreaterThan(0);
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.deleted',
'data' => ['object' => ['customer' => 'cus_test123']],
]));
expect(DB::table('features')->where('scope', 'account|'.$this->account->id)->count())
->toBe(0);
});
test('subscription updated with same plan does not flush the pennant cache', function () {
$starter = Plan::query()->where('slug', 'starter')->firstOrFail();
$this->account->update(['plan_id' => $starter->id]);
Feature::for($this->account)->value(WorkspaceLimit::class);
$cachedRow = DB::table('features')
->where('scope', 'account|'.$this->account->id)
->first();
expect($cachedRow)->not->toBeNull();
$this->listener->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'customer' => 'cus_test123',
'items' => ['data' => [['price' => ['id' => 'price_starter_monthly']]]],
]],
]));
$stillCachedRow = DB::table('features')
->where('scope', 'account|'.$this->account->id)
->first();
expect($stillCachedRow->id)->toBe($cachedRow->id);
});

View file

@ -11,27 +11,36 @@
use Illuminate\Support\Facades\DB;
use Laravel\Pennant\Feature;
test('booted hook flushes pennant cache when plan_id changes', function () {
test('forgetPlanFeatureCache drops the cached plan-scoped features', function () {
$starter = Plan::where('slug', 'starter')->first();
$plus = Plan::where('slug', 'plus')->first();
$account = Account::factory()->create(['plan_id' => $starter->id]);
expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($starter->workspace_limit);
expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($starter->social_account_limit);
expect(Feature::for($account)->value(MemberLimit::class))->toBe($starter->member_limit);
expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe($starter->monthly_credits_limit);
// Prime the Pennant cache against the starter plan.
Feature::for($account)->value(WorkspaceLimit::class);
Feature::for($account)->value(SocialAccountLimit::class);
Feature::for($account)->value(MemberLimit::class);
Feature::for($account)->value(MonthlyCreditsLimit::class);
expect(DB::table('features')->where('scope', 'account|'.$account->id)->count())
->toBe(4);
// Move the account to a plan with different limits and forget the cache.
$account->update(['plan_id' => $plus->id]);
$account->forgetPlanFeatureCache();
$account->load('plan');
expect(DB::table('features')->where('scope', 'account|'.$account->id)->count())
->toBe(0);
expect(Feature::for($account)->value(WorkspaceLimit::class))->toBe($plus->workspace_limit);
expect(Feature::for($account)->value(SocialAccountLimit::class))->toBe($plus->social_account_limit);
expect(Feature::for($account)->value(MemberLimit::class))->toBe($plus->member_limit);
expect(Feature::for($account)->value(MonthlyCreditsLimit::class))->toBe($plus->monthly_credits_limit);
});
test('booted hook does not flush pennant when other fields change', function () {
test('updating non-plan fields does not flush the pennant cache', function () {
$plan = Plan::where('slug', 'plus')->first();
$account = Account::factory()->create(['plan_id' => $plan->id]);