Laravel Billing: one package, every gateway, working on day one

php dev.to

Every SaaS billing integration starts the same way: you pick a provider, pull in their package, wire it up — and three months later when the business wants to add a second gateway (or swap to a Malaysian one like BayarCash or ToyyibPay because Stripe doesn't do local rails), you discover your entire subscription layer is welded to the first provider's package. Different webhook shapes, different status vocabularies, different model assumptions. You're not adding a gateway; you're re-architecting.

I kept watching this happen — especially in the Malaysian market, where the "obvious" global packages assume a gateway that half my clients can't actually use. So I built cleaniquecoders/laravel-billing around one inversion: the gateway is the plugin, not the package. The engine owns subscription and invoice state. A gateway is a single contract your app implements. This post is less about the API surface and more about why it's shaped this way — because the shape is the whole point.

The core decision: one package, gateways as a contract

The temptation when building a billing library is to ship laravel-billing-stripe, laravel-billing-bayarcash, laravel-billing-toyyibpay, and so on. It feels modular. It's actually a maintenance trap — every gateway sub-package re-implements the same subscription lifecycle slightly differently, and the core can never assume a stable shape because each adapter bends it.

This package goes the other way. There is one package, one repo, and it never references a real provider by name. Instead there's a single extension point:

namespace CleaniqueCoders\LaravelBilling\Contracts;

interface PaymentGateway
{
    public function createCheckout(
        Billable $billable,
        Plan $plan,
        PlanInterval $interval,
        string $returnUrl,
    ): CheckoutIntent;

    public function cancel(Subscription $subscription): void;

    public function parseWebhook(Request $request): ?WebhookEvent;
}
Enter fullscreen mode Exit fullscreen mode

Three methods. That's the entire surface your app implements to onboard BayarCash, ToyyibPay, Chip, senangPay, Stripe, or anything else. The trick that makes it hold together is the two DTOs at the boundary — CheckoutIntent going out, WebhookEvent coming back:

final class CheckoutIntent
{
    public function __construct(
        public string $redirectUrl,   // where to send the customer
        public string $externalId,    // echoed back by the webhook for correlation
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

The gateway's job is to translate the provider's idiosyncratic world into these two neutral shapes. Once it does, the engine — subscription transitions, invoice issuance, events — never needs to know which provider it's talking to. The provider-specific mess is quarantined inside one class instead of leaking through your whole billing layer. That's the package-worthy lesson here, independent of billing: when you integrate N external services that do conceptually-the-same thing, define your own DTO at the boundary and make each adapter responsible for the translation. Don't let provider shapes propagate inward.

Batteries included: a gateway that needs no merchant account

Here's the part I'm most pleased with. A fresh install defaults to BILLING_GATEWAY=local, and the bundled LocalGateway runs the entire subscribe → activate → invoice → receipt flow with no real money and no merchant account. You composer require, run migrations, and the billing flow works immediately — in demo, in development, in UAT, in CI.

But it's not a stub. This is the detail that matters:

// LocalGateway::createCheckout — approval flows through the SAME
// WebhookEvent path a real gateway uses
return new WebhookEvent(
    type: WebhookEventType::SubscriptionActivated,
    externalId: $payload['external_id'],
    amountCents: $payload['amount_cents'] ?? null,
    providerEventId: 'local-'.$payload['external_id'],
    rawPayload: $payload,
);
Enter fullscreen mode Exit fullscreen mode

When you click "Approve" on the local dev checkout page, it produces a WebhookEvent and runs it through Billing::handle() — the exact same code path a real BayarCash webhook would take. It even HMAC-signs its checkout token with your app.key and verifies the signature on the way back, so signature-verification logic is exercised too:

public static function verify(string $token): ?array
{
    [$data, $signature] = explode('.', $token, 2);
    $expected = hash_hmac('sha256', $data, static::key());

    if (! hash_equals($expected, $signature)) {
        return null; // tampered or invalid
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Why go to this trouble for a "dev" gateway? Because a fake that takes a different path than production is worse than no fake — it gives you false confidence. By making the local gateway flow through the real activation pipeline, your tests against local actually validate the pipeline a paying customer will hit. Set BILLING_LOCAL_AUTO=true and the whole thing runs synchronously in a single request, which is perfect for CI and feature tests. The local routes also refuse to register in production, so there's no footgun.

Headless core, optional UI

The engine — models, services, contract, events, the manager — works with no UI at all. If you want billing pages fast, there's an opt-in Livewire + Flux UI (plan picker, billing portal, receipt card) that closes the full loop. The guard is clean:

if (config('billing.routes.enabled') && class_exists(Livewire::class)) {
    // register /billing routes
}
Enter fullscreen mode Exit fullscreen mode

If Livewire isn't installed, or you set BILLING_UI_ENABLED=false, the package stays fully headless and you build your own pages against the same models and facade. No hard dependency on the UI stack bleeds into the core. This is the right default for a library: the opinionated convenience layer is there if you want it, but it's behind a class_exists check and a config flag, never mandatory.

The webhook flow, and a replay guard worth stealing

Your app owns the route; the package does the work:

Route::post('/webhooks/{gateway}', function (Request $request, string $gateway) {
    $event = Billing::gateway($gateway)->parseWebhook($request);
    abort_if($event === null, 401);

    Billing::handle($event); // dedups, transitions state, issues invoices, fires events
    return response()->noContent();
});
Enter fullscreen mode Exit fullscreen mode

parseWebhook() (your gateway's code) verifies the signature and normalises the payload, or returns null to reject it. Then Billing::handle() delegates to a WebhookProcessor that replay-guards, locates the subscription, transitions status, issues an invoice on activate/renew, and fires the matching domain event.

The replay guard is a small thing I like:

protected function isReplay(WebhookEvent $event): bool
{
    if ($event->providerEventId === null) {
        return false;
    }

    $key = 'billing:webhook:'.$event->providerEventId;
    $ttl = (int) config('billing.webhook.replay_ttl', 60 * 60 * 24 * 30);

    // Cache::add returns false when the key already exists → replay.
    return Cache::add($key, true, $ttl) === false;
}
Enter fullscreen mode Exit fullscreen mode

Gateways retry. They send the same event twice, three times, because they didn't get your 200 fast enough. If you don't dedup, you double-issue invoices. The neat part is leaning on Cache::add's atomicity — it only writes if the key is absent and tells you whether it won the race, in one operation. No read-then-write window for a concurrent duplicate to slip through. That's a reusable pattern for any idempotent-event handling, not just billing.

State transitions live in one place

WebhookProcessor is where provider events become subscription state, and it reads like a state machine:

match ($event->type) {
    WebhookEventType::SubscriptionActivated => $this->activate($subscription),
    WebhookEventType::SubscriptionRenewed   => $this->renew($subscription),
    WebhookEventType::PaymentSucceeded      => $this->paymentSucceeded($subscription, $event),
    WebhookEventType::PaymentFailed         => $this->paymentFailed($subscription, $event),
    WebhookEventType::SubscriptionCanceled  => $this->cancel($subscription),
};
Enter fullscreen mode Exit fullscreen mode

The gateway's only responsibility is mapping its provider's vocabulary onto these five WebhookEventType cases. Everything downstream — what "activate" means for period dates, when an invoice gets issued, which event fires — is decided once, in the engine, regardless of provider. A SubscriptionStatus enum carries its own access logic so the rule isn't scattered:

public function grantsAccess(): bool
{
    return match ($this) {
        self::Trialing, self::Active, self::PastDue => true,
        self::Canceled, self::Incomplete => false,
    };
}
Enter fullscreen mode Exit fullscreen mode

Note PastDue still grants access — a failed renewal shouldn't instantly lock someone out mid-period. That's a deliberate dunning-friendly choice, and because it lives on the enum, it's consistent everywhere access is checked.

Polymorphic billing: tenancy is optional

The bill target is polymorphic, so the same engine serves single-tenant (User) and multi-tenant (Team/Workspace/Organization) without caring which:

class User extends Authenticatable implements Billable
{
    use HasSubscriptions;
}
Enter fullscreen mode Exit fullscreen mode

HasSubscriptions satisfies the whole Billable contract and gives you the accessors the engine and UI depend on — subscription(), subscribedTo('pro'), onTrial(), onGracePeriod(), plan(), invoices(), plus metered-usage gating via canConsume('seats', 1) / recordUsage('seats', 1). To scope billing to a team instead of the logged-in user, you point one config closure at it:

'billable_resolver' => fn ($request) => $request->user()->currentTeam,
Enter fullscreen mode Exit fullscreen mode

Every UI query and every invoice download is constrained to the resolved billable, and the download routes 403 on a foreign invoice — so one tenant can never see another's invoices. Tenancy didn't require a tenancy feature; it fell out of making the target polymorphic and routing all access through one resolver.

A few more details worth noting

Snapshot vs live. A subscription stores plan_tier as a snapshot string, but the live Plan is resolved from the repository at read time. So plan definitions can live in config or a database table (same PlanRepository interface either way), and a subscriber's tier reference survives even if you restructure your plan models.

Atomic invoice numbers. Sequential numbering (INV-2026-000001) is allocated in a row-locked transaction, so concurrent issuance never collides on a number:

$sequence = $sequenceModel::query()->where('year', $year)->lockForUpdate()->first();
$current = (int) $sequence->next_number;
$sequence->next_number = $current + 1;
$sequence->save();
Enter fullscreen mode Exit fullscreen mode

Malaysia-friendly, neutrally. MYR default, an SST/SSM-aware tax-invoice template, configurable seller details — but all neutral by default, so it's not only a Malaysian package. The tax math is just round(subtotal * rate), stored as a breakdown on the invoice so the PDF renders correctly.

Events as your extension seam. The engine only updates state and issues invoices. Provisioning access, dunning emails, Slack pings — those are your listeners on SubscriptionActivated, SubscriptionRenewed, SubscriptionCanceled, PaymentSucceeded, PaymentFailed, InvoiceIssued. The package doesn't presume to know your side effects.

When you'd reach for this

It fits when you want subscription + invoicing in Laravel and:

  • you need more than one gateway, or a Malaysian gateway, or the freedom to swap later without re-architecting;
  • you want the full flow working on day one — demo, UAT, CI — before any merchant account exists;
  • you want a headless engine you can drive from your own UI, with an optional bundled UI when you're moving fast;
  • you bill teams or workspaces, not just users;
  • you're in a SST/SSM context and want sane local invoicing without a provider lock-in.

If you're all-in on a single global gateway forever and its first-party Laravel package covers you, use that. The value here shows up the moment "which gateway" becomes a question with more than one answer — which, for anyone building for the Malaysian market, it always is.

It's MIT-licensed and on Packagist:

composer require cleaniquecoders/laravel-billing
Enter fullscreen mode Exit fullscreen mode

Repo and full docs (architecture, gateways, the full billing cycle, writing your own driver): github.com/cleaniquecoders/laravel-billing.

Implementing a gateway is one class and three methods — if you write a BayarCash or ToyyibPay driver, I'd love to see it.

Source: dev.to

arrow_back Back to Tutorials