Plan providers
Which plan an account is on is worked out, not stored. Add HasPlans and it is resolved by a list of providers, tried in order, first answer wins.
'plan_providers' => [ PlanProviders\ForcedPlanProvider::class, // a column of yours, set by hand PlanProviders\CashierPlanProvider::class, // the subscription, by price id PlanProviders\StoredPlanProvider::class, // credits()->setPlan(), then the default ],
The order is the policy. Forced before Cashier means a plan somebody set by hand for a partner, a demo or a courtesy account beats what Stripe thinks, because a person decided it deliberately. Reverse the two and a support agent's decision is silently undone by the next subscription read.
A provider returns null to mean not my business, which is how the chain continues. Only the last one in the list should be capable of always answering.
The three that ship
ForcedPlanProvider
Reads a column of yours, named in config, and uses it if it names a plan that exists:
'override_column' => 'forced_plan',
null switches the provider off, which is the default, so it costs nothing to leave in the list. An unknown handle in the column is ignored rather than resolving to an empty plan, so a typo falls through to the next provider instead of silently removing somebody's allowance.
CashierPlanProvider
Matches the live subscription to a plan by price identifier:
'price_id_key' => 'stripe_price_id', 'subscription_type' => 'default',
It walks your plans, takes the value under price_id_key from each, and returns the first whose price the model is subscribed to.
It is inert without Cashier installed. The provider checks for the Cashier class and for subscribed() and subscribedToPrice() on the model, and returns null if any is missing, so leaving it in the list costs an application that sells credit bundles nothing at all.
StoredPlanProvider
The plan stored on the account row, then the configured default:
'default_plan' => 'free',
Last in the order, and the only one an application without subscriptions ever reaches. Set default_plan to null if credits are only ever bought, and accounts will resolve to a plan that does not exist, which is a valid state.
Writing your own
For Paddle, LemonSqueezy, an internal entitlements service or a feature flag, implement the contract and add the class to the list:
namespace App\Billing; use EduLazaro\Larameter\Contracts\PlanProvider; use EduLazaro\Larameter\Plan; use EduLazaro\Larameter\Plans; use Illuminate\Database\Eloquent\Model; class PaddlePlanProvider implements PlanProvider { public function provide(Model $model): ?Plan { $handle = $this->entitlements->tierFor($model); return Plans::exists($handle) ? Plans::find($handle) : null; } }
Providers are resolved through the container, so constructor injection works.
Plans::find() builds the Plan from your plans file, which is what keeps a provider's answer indistinguishable from any other's. Construct a Plan directly only when the definition genuinely does not live in the plans file.
One list, and when not to have one
One list for the whole application, because how billing works has one answer per project. Override it only when one model is billed differently from another:
class Reseller extends Model { use HasPlans; protected array $planProviders = [ResellerPlanProvider::class]; }
or at runtime, which is mostly a testing tool:
Organization::setPlanProviders([FakePlanProvider::class]); Organization::flushPlanProviders();
Resolution order for what applies: providers registered with setPlanProviders(), then the $planProviders property, then the config list.
Memoisation
plan() is memoised per model instance, because resolving can query a subscription and the plan is asked for repeatedly within a request. After changing a subscription mid request:
$org->forgetPlan();
credits()->setPlan() already calls it for you.
The Account model memoises separately, and asks the metered model first: with HasPlans present, Account::plan() delegates to $model->plan() rather than reading its own column. Reading the column regardless would let plan() answer one thing while the credits came from another.