A while back in this series I shipped the billing seam and was very careful to say it was not billing. The free core got the whole shape of a subscription system, a gateway contract, a driver manager, a real access gate, and it could not take a single cent. That was on purpose. The cent-taking part lives in a separate paid add-on, and this post is about finishing the Lean MVP of that add-on.
LaraFoundry is a SaaS core I am extracting in public from a CRM that already runs in production, one module at a time. The core is free and stays free for everything except money. Auth, multi-tenancy, RBAC, the admin console, the activity log, i18n, files: all free. The day a business wants to charge its own customers, that is the paid part. So billing could not be "just another module." It had to split down a clean line: the free side carries the structure, the paid side carries the parts that actually move money.
Here is what "Lean MVP done" means concretely. Plans with real prices. Checkout that opens a real hosted payment page. Promo codes and a free first month. Subscription enforcement that blocks access when the money runs out. An owner-facing billing portal and a super-admin console to watch payments and manage promo codes. A reminder cron before a subscription lapses. And the single decision that shaped every line of it: none of it is married to one payment provider, and nothing is priced in one hardcoded currency.
The constraint that shaped everything
Most Laravel SaaS starters are Stripe-only. Stripe is excellent, and for a lot of builders that is the right and only answer. But Stripe reaches roughly 46 countries, and the market I build for is not one of them. If I bake Stripe into the call sites, I have quietly built a core that only serves the countries Stripe serves.
So the brief I gave myself was uncomfortable on purpose: build billing as if I do not know which provider the host will use, and as if the host's customers do not all pay in dollars. Everything below falls out of taking that seriously.
One contract, many drivers
The free core ships a single contract that describes only the mechanics of moving money:
interface PaymentGatewayInterface
{
public function subscribe(Tenant $tenant, string $planId, string $period, array $options = []): array;
public function cancel(Tenant $tenant, bool $atPeriodEnd = true): void;
public function refund(string $chargeReference, ?int $amount = null): void;
public function subscriptionStatus(Tenant $tenant): string;
public function verifyWebhook(Request $request): array; // returns the verified payload, throws if not authentic
}
That is the entire surface. Notice what is not in it: nothing about tax, nothing about invoicing. That omission is deliberate. Tax responsibility differs by the kind of provider. With a PSP like Stripe the business is the merchant of record and owns its own VAT. With a merchant of record like Paddle, the provider owns the tax. If I folded a chargeTax() or an invoice() into the gateway method, I would bake one of those two models into both, and it would be wrong for the other half of my providers.
The paid add-on registers real drivers against that contract, in the same style as Laravel's own Mail or Queue managers:
// In the add-on's service provider. The form is public API; the driver bodies are the add-on.
$manager->extend('stripe', fn () => new CashierStripeDriver(/* ... */));
$manager->extend('paddle', fn () => new CashierPaddleDriver(/* ... */));
A config key picks the active driver. The call sites that start a subscription or read its status never know which gateway they got. A host in a country neither Stripe nor Paddle reaches can register its own local PSP against the same contract and change one config value. Swapping the entire payment provider is not a refactor, it is a string.
Both real drivers are built on Laravel Cashier (the Stripe one on laravel/cashier, the Paddle one on cashier-paddle). Cashier and not Spark, because Spark is an opinionated whole-app billing UI and I needed a gateway, not a front end. The driver bodies, the webhook verification, the column mirroring, are the proprietary part of the add-on, so I will describe them rather than dump them. The shape is: the driver opens a hosted checkout and hands back a redirect, and a webhook listener mirrors the provider's subscription state back onto the company's own columns, so the rest of the app reads one set of columns no matter which provider wrote them.
Stripe and Paddle are not the same animal, and the contract has to absorb that without leaking it. Stripe's subscribe returns a hosted Checkout URL to redirect to. Paddle's overlay wants a payload handed to its JavaScript instead of a server redirect. Both satisfy the same method signature and return shape, and the caller treats them identically. The one honest seam in the contract is refund: against a merchant of record, a programmatic refund goes through the provider's own adjustments flow, so that path is stubbed to throw clearly rather than pretend.
Plans are priced per currency
The other half of "not US-first" is the money itself. A plan in LaraFoundry does not have a price. It has a price per currency:
$plan = $plans->find('business');
$plan->priceFor('month', 'EUR'); // 1900 (minor units, 19.00 EUR)
$plan->priceFor('year', 'PLN'); // 79000 (790.00 PLN)
There is no single USD figure with a converter bolted on at display time. A customer in Warsaw is billed in their currency, with a price I actually chose for that currency, not a live FX-rounded approximation of a dollar amount. The currency is part of the plan definition, decided up front, not computed in the view. The PlanContract and priceFor live in the open core, so a free self-host gets the currency-first shape for free; the paid add-on supplies the config-backed plans and maps each plan and currency to the right provider price ID.
Where the free/paid line falls
This is the part I keep coming back to across the whole series. The free core ships the seam: the gateway contract, the access gate on the company model, and the subscription columns themselves (trial_ends_at, subscription_ends_at, plan_id). With billing turned off, which is the default, the gate is always open and the core is a complete multi-tenant app with no paywall anywhere. That is the entire promise of the free core.
The paid add-on fills the seam. It binds the real gateways, it writes those columns from webhooks, and it closes one more loop: entitlements. Access in a SaaS is really two independent questions, and collapsing them is a classic bug. RBAC answers "can this user do it." Entitlement answers "did this company's plan pay for it." A manager can legitimately hold production.view in RBAC while the company sits on a plan that never bought the Production module. So a route is gated by both at once:
Route::middleware([
'can:production.view', // RBAC: who in the company may
'entitlement:production.module', // billing: what the plan paid for
])->get('/production', ProductionController::class);
The free core ships an entitlement resolver that is open by default. The add-on rebinds it to read the plan's features and refuse anything the plan did not buy. Billing off, every feature is open. Billing on, it is fail-closed and resolved server-side, with nothing to spoof from the request.
Enforcement of an expired or unpaid subscription has two modes the host picks. Soft mode lets the request through but surfaces the state, so you can nudge before you lock. Hard mode redirects to a blocked page. One case is always hard regardless of mode: if the account owner's own access is revoked, the block cascades to the whole company, because a company cannot operate on a banned owner's seat. A daily cron warns owners a few days before a subscription lapses, idempotently, so nobody gets the same reminder twice.
The surfaces, briefly
Two UI surfaces shipped with the MVP. The owner gets a billing portal: pricing, checkout, payment history, and the blocked page. The super-admin gets a console: a read-only list of payments (totalled per currency, with no converter, because the per-currency truth is the honest one) and full CRUD over promo codes. Both are built as Inertia + Vue pages the host publishes, the same delivery pattern the rest of the core's frontend uses, so the add-on does not drag in a second build pipeline.
The proof, because every release claims one
All of this sits under Pest. 241 tests in the add-on alone. I lean on tests hard here for a specific reason: a billing gate you cannot trust is worse than no gate, and the failure modes are quiet. A webhook listener that writes a null expiry date silently revokes a paying customer. A fail-open guard silently gives away the paid tier. Several of those exact bugs got caught by a test or an adversarial review pass before they ever shipped, and that is the whole argument for writing them.
The honesty section
Every post in this series has one, and this one earns a big one because "billing is done" is an easy thing to overclaim.
This is a Lean MVP, not a finished billing product. There is no affiliate program yet; that is a deliberately later milestone. The entitlement loop checks plan membership, the "is the subscription still alive" axis is enforced separately, and I am not pretending those are the same check.
And the big one: I have built and tested all of this, but I have not yet run a live charge against a real Stripe or Paddle account end to end. The drivers are exercised against the providers' test surfaces and a wall of Pest tests, not against a production merchant account with a real card. That last mile, real keys, a real webhook tunnel, a real subscription, is a separate step I have not taken yet, and I am not going to call the engine battle-tested until I have.
One more, on what is open. The core seam is open source and you can read all of it: the contract, the manager, the access gate, the entitlement resolver, the currency-first plan shape. The add-on is a paid, proprietary package, so the driver bodies, the webhook verification, and the enforcement internals are described here, not dumped. The free core staying genuinely free is what pays for that line existing at all.
The thread, again
Every phase in this series lands on the same shape: the interesting engineering is fine, but the real lesson is in a default that was right for one app and wrong for a reusable one. For billing it was two defaults at once. One provider, because the app I extracted from only ever needed one. One currency, because its one user paid in one. Both were perfectly fine for a CRM with a single customer, and both would have quietly fenced the reusable core into a single market. The work was not building Stripe support. It was building billing so that Stripe is one swappable cartridge, and so the price tag is not in dollars by assumption.
Follow along
- Code, versioned and Pest-tested before each tag: github.com/dmitryisaenko/larafoundry
- The project and roadmap: larafoundry.com
Top comments (0)