Building a SaaS engine in public: shipping the billing seam, not billing

php dev.to

I tagged v0.9.0 of LaraFoundry this week: billing. Except the honest headline is that I shipped the billing seam, not billing. The free core now has the whole shape of a subscription system, a payment-gateway contract, a driver manager, a real access gate over subscription columns, and it cannot take a single cent. That is on purpose, and the reason is the most interesting part of the phase.

LaraFoundry is a SaaS core I'm extracting in public from a live CRM, one module at a time. The deal I made with myself early on: 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 customers, that is the paid part. So billing could not just be "another module." It had to split cleanly down a line, with the free side carrying real, useful structure and the paid side carrying the parts that actually move money.

The donor habit I would not carry

Here is what the original CRM did when a company "paid" for its subscription:

// TODO: real payment gateway integration
// TEMPORARY: every payment is successful, for testing
$paymentStatus = 'success';
Enter fullscreen mode Exit fullscreen mode

That success was hardcoded. There was no Stripe, no Paddle, no gateway at all. "Paying" wrote a row into a company_payments table and flipped the subscription date forward. For a CRM I run myself, with one real user, that was fine: I never needed the real thing, so the placeholder sat there indefinitely.

The moment this becomes a reusable core, that placeholder is poison. A success that is always true is worse than no gateway, because it looks like billing works. So the rule for this phase was simple: the fake gateway does not get extracted. Whatever stands in its place has to be honest about the fact that it takes no money.

What the seam actually is

The free core ships a PaymentGatewayInterface: subscribe, cancel, refund, status, and verify a webhook. It describes only the mechanics of moving money. It deliberately says nothing about tax or invoicing, because that responsibility differs by gateway type (with a PSP like Stripe the client is the merchant of record and owns the VAT; with a merchant-of-record like Paddle the provider owns it). Folding tax into a gateway method would bake one wrong model into both.

The core registers exactly one driver against that contract: the null gateway. It refuses every money operation, loudly:

public function subscribe(Tenant $tenant, string $planId, string $period, array $options = []): array
{
    throw $this->notConnected();
}
Enter fullscreen mode Exit fullscreen mode

No silent success. If something calls it without a real driver bound, it fails fast and tells you to install the add-on. The driver is resolved by a manager in the same style as Laravel's Mail or Queue manager: a config key picks the driver, the add-on registers real ones (stripe, paddle) via extend(), a host in a country those don't reach can register its own local PSP. Swapping providers is one config value, and no call site knows which gateway it got. That gateway-agnostic shape is the part I think matters most for non-US builders: most Laravel SaaS starters are Stripe-only, and Stripe reaches roughly 46 countries.

The gate that does the real work

The one piece of "billing logic" that genuinely belongs in the free core is the access decision. Before this phase, Company::hasAccess() was a stub that returned true, a seam planted back in the RBAC phase so call sites would not change when billing landed. Now it reads real state:

public function hasAccess(): bool
{
    if (! LaraFoundryBilling::enabled()) {
        return true;
    }

    return $this->subscriptionState()->hasAccess();
}
Enter fullscreen mode Exit fullscreen mode

With billing disabled (the default), it is always true. The free core is a complete multi-tenant app with no paywall, and that is the whole promise. Turn billing on and it reads the subscription columns: a live trial or an active subscription grants access, anything else denies. Fail-closed when enabled, fully open when not.

The subscription columns (trial_ends_at, subscription_ends_at, plan_id, ...) ship in the free core's migration, not the add-on's. That is a deliberate call: the gate has to read real state with no add-on installed, and a free self-host should be able to grant a trial by hand. The add-on only writes those columns (its webhook keeps subscription_ends_at current). It adds no column of its own to the core table. And those columns are not mass-assignable, so a tenant can never POST its way to a free year of subscription.

The honesty section, because every release has one

The thing I am most careful not to overclaim: this phase wires the gate but no caller consults it yet. The intended loop is that the RBAC policy checker, or a "subscription required" middleware, asks hasAccess() before letting a request through. That wiring is not in this phase. So today, enabling billing makes the gate answer correctly, but nothing in the core enforces it. Returning real state now means those future call sites won't have to change when they arrive, which was the entire point of planting the stub two phases ago. But "the paywall enforces itself" is not a claim I get to make yet, and I'm not making it.

And the obvious one: there are no real payments here. No Stripe, no Paddle, no Cashier, not even as a dependency. No plans, no promo codes, no trial UI, no billing portal, no revenue metrics. All of that is the paid larafoundry-billing add-on, a separate package, a later milestone. What shipped is the contract those things stand on.

The thread, again

Every phase in this series lands on the same shape. The interesting engineering is fine; the lesson is in a default or a habit that was right for one app and wrong for a reusable one. This time it was a hardcoded success: a placeholder that worked perfectly for a CRM with one user and would have been a quiet disaster in a core other people deploy. The fix was not to build a real gateway. It was to build the honest absence of one: a seam that takes no money and says so.

The billing engine is the add-on. Drawing the free/paid line so the core stays genuinely free was the work.

Follow along

Source: dev.to

arrow_back Back to Tutorials