Skip to main content

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 is billing.default unless 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 /pricing page and an upgrade grid of its own. tiers() / purchasable() list the catalog; cards() and upgradeCards($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; ConfigTierCatalog by default) — all(), find($key), label($key), priceDisplay($key), and the flags isByok($key) / isUntouchable($key).
  • ConfigAddonCatalog — all(), exists($key), label($key), priceFor($key), providerPriceFor($key), and grantsFor($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.


← Back to the documentation index