Tiers and pricing
Define your tiers in config/billing.php:
// config/billing.php
'tiers' => [
'free' => ['label' => 'Free'],
'pro' => [
'label' => 'Pro',
'provider_price' => env('BILLING_PRICE_PRO'), // the active provider's price reference
'price_display' => ['amount' => 1900, 'currency' => 'EUR'],
'interval' => 'month',
],
],
A tier may carry 'byok' => true — a "bring your own keys" tier, where the customer runs on their own
provider credentials rather than being metered and billed the usual way. The package exposes it through
Entitlements::isByok() / TierCatalog::isByok($key); your app reads that flag to route a BYOK owner around
its own metering/server-key path. The package itself holds no server-side execution key, so it never puts a
BYOK owner on a metered server path — that boundary lives in your app, and isByok() is the seam it reads.
BYOK is orthogonal to payment method: a BYOK tier can still be a paid subscription.
Each paid tier's provider_price is the active provider's price reference — provider-neutral, not
hardcoded to one provider. It may be declared two ways:
- a scalar string — one price for whichever driver is active (the single-provider common case). On Stripe
that string is a Stripe price id (
price_...); create the product and its recurring price in the Stripe dashboard or via the API, then put the id in.env. - a per-provider map —
['stripe' => 'price_...'], so one tier config carries the right id for each driver; the active driver isbilling.defaultunless a provider is named.
BILLING_PRICE_PRO=price_...
A tier whose provider_price is empty (or a map with no entry for the active provider) cannot be checked out
— it resolves to null and the tier is treated as not purchasable on that provider, never charged at a wrong
price. That resolution is the anti-price-injection allowlist: the client submits a tier key, and the
ProviderPriceResolver maps it to a price only config declares, so a price id can never be smuggled in from
the request. Read a resolved id with ProviderPriceResolver::forTier($key) / forAddon($key).
One pricing source
For a pricing surface your application builds — a public /pricing page, or an upgrade grid of its own —
PricingCatalog::cards() and upgradeCards($currentKey) are one config-authoritative source, so two such
surfaces can never promise different things. The account hub's plan list reads each plan's label and price from
the same tier catalog and shows no bullets, highlight or badge. A tier's feature bullets live in config as an
ordered list of translation keys (your app owns the strings, in every locale); an optional
highlight/badge emphasizes a card:
'pro' => [
'label' => 'Pro', /* … price as above … */
'features' => ['pricing.pro.projects', 'pricing.pro.priority_support'], // i18n KEYS, never raw text
'highlight' => true,
'badge' => 'pricing.badge.popular',
],
PricingCatalog::bulletsFor($tierKey) resolves those keys to the current locale, in order. Because the
bullets come only from config, two surfaces that render them cannot drift.
Resolving a billable to its tier
TierResolver maps a billable to its tier. With Stripe the package binds ColumnTierResolver by default — it
reads the denormalized plan column (billing.tier_column), which the Stripe webhooks keep current. With Mollie it
binds SubscriptionTierResolver, which reads the tier off the subscription row, because nothing on the local
engine writes that column. The account hub asks the same resolver. If your app runs Stripe and does not keep a
tier column, rebind it to SubscriptionTierResolver in one line:
// A service provider
$this->app->bind(
Pushery\Billing\Contracts\TierResolver::class,
Pushery\Billing\Resolvers\SubscriptionTierResolver::class,
);
Grandfathering is handled through per-tier legacy_prices; a multi-item subscription is handled too (the
tier item is resolved by price, foreign items untouched).
The catalog services
The tier, add-on and pricing surfaces are read through config-authoritative catalog services — resolve them from the container and use them out of the box; the config is the single source they read, so nothing drifts.
PricingCatalog— the shared source for the pricing surfaces your application builds, a public/pricingpage and an upgrade grid of its own.tiers()/purchasable()list the catalog;cards()andupgradeCards($currentKey)build the render-ready cards;bulletsFor($key)resolves a tier's feature bullets to the current locale. Surfaces that read the one source can never promise different things.TierCatalog(contract;ConfigTierCatalogby default) —all(),find($key),label($key),priceDisplay($key), and the flagsisByok($key)/isUntouchable($key).ConfigAddonCatalog—all(),exists($key),label($key),priceFor($key),providerPriceFor($key), andgrantsFor($key)for a prepaid-unit add-on (see Usage-based billing). Unlike the tier and plan catalogs it is a concrete class, not a contract, so extend it by binding your own subclass rather than rebinding an interface.
Use them as-is for the common case; extend by rebinding the contract when you need catalog data from
somewhere other than config (a database of tiers, say). Rebind TierCatalog (or PlanCatalog) to your own
implementation the same way TierResolver is rebound above — every package surface then reads your catalog,
and your pricing surfaces stay in lockstep because they still go through PricingCatalog.
Feature bullets, without drift
A tier's feature bullets are the features block of i18n keys shown above — never raw strings. The keys
resolve through the translation namespace, so the same bullet renders in every locale, and every surface
that shows them shows the same list, because each calls PricingCatalog::bulletsFor(). To change a bullet you
edit the config key order or the translation, never two copies of the text. See
i18n and translations for overriding those strings and the register they must
keep.
A bullet whose number follows from your own limits, a count of translations a month worked out from a budget,
belongs in code rather than in a translation. Bind Pushery\Billing\Contracts\FeatureBullets and answer each
tier's bullets yourself; PricingCatalog then asks your implementation for every card, the full set and the
upgrade grid alike, and a changed limit changes the bullet without anybody editing a translation:
use Pushery\Billing\Contracts\FeatureBullets;
final class LimitBullets implements FeatureBullets
{
public function forTier(string $tierKey): array
{
$budget = config("plans.{$tierKey}.monthly_budget", 0);
return [__('pricing.translations', ['count' => intdiv($budget, 150)])];
}
}
// in a service provider
$this->app->bind(FeatureBullets::class, LimitBullets::class);
Each bullet is a finished string in the current locale. Without a binding the configured keys answer as above.