Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Billing Laravel Package

moffhub/billing

Feature-based subscription billing for Laravel with African payment support: gate routes by feature, meter usage, manage subscriptions and invoices, and accept payments via M-Pesa, Airtel Money, Paystack, Flutterwave, and 10+ more through one unified API.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and wire it up (10 minutes):

composer require moffhub/billing
php artisan vendor:publish --tag=billing-config
php artisan vendor:publish --tag=billing-migrations
php artisan migrate

Add the trait to whatever actually pays — usually Company/Team, not User. Pick this deliberately on day one: retrofitting a second billable model later means re-keying every subscription, usage record, and invoice.

use Moffhub\Billing\Traits\Billable;

class Company extends Model
{
    use Billable;
}

Where to look first, in this order:

  1. config/billing.php — this is the real map of the package. Read the routes, webhooks.providers.*, and BILLING_ENABLED_PROVIDERS keys before writing any code.
  2. docs/PROVIDERS.md — the provider matrix decides your whole checkout design, so read it before choosing a driver.
  3. docs/API.md — 32 endpoints; useful as a reference for the JSON shapes the models produce.
  4. PaymentManager and the Billable trait — everything else is a thin wrapper over these two.

First use case: gate one premium route behind one feature and get paid for it via a single provider. Don't start with the full catalogue.

Feature::create([
    'slug' => 'ocr_scanning', 'name' => 'OCR Scanning',
    'type' => 'metered', 'is_addon' => true, 'addon_price' => 2000,
]);

Plan::create([
    'ulid' => Str::ulid()->toBase32(),
    'name' => 'Standard', 'slug' => 'standard',
    'base_price' => 750000, 'billing_cycle' => 'monthly',
    'trial_days' => 14,
    'features' => ['gatebook', 'incidents'],
    'limits' => ['max_posts' => 10, 'ocr_scanning' => 100],
]);

Then Route::middleware(['feature:ocr_scanning', 'usage:ocr_scanning']) and you have the whole loop: plan → feature → limit → enforcement. Seed with php artisan billing:sync-plans --seed for local/dev parity with production.

Implementation Patterns

Gate everything by feature slug, never by plan name. The package's central design bet is that plans are just bundles. onPlan('standard') is fine for cosmetic UI badges (@plan('professional')), but access decisions should be hasFeature() or feature: middleware so that repackaging plans doesn't require a code change:

Route::middleware(['feature:ocr_scanning', 'usage:ocr_scanning'])->group(function () {
    Route::post('ocr/scan', ScanController::class);
});

Route::middleware(['plan:professional,enterprise'])->group(function () {
    Route::get('analytics/advanced', AnalyticsController::class);
});

Note the semantics: feature: is AND, plan: is OR. Combining feature: + usage: is the pattern that matters — the first is "did you buy it", the second is "have you got quota left".

Meter at the service layer, not the controller. Usage recording is a domain concern that belongs next to the thing being metered:

$usage = app(UsageService::class);

$usage->record($company, 'ocr_scanning', quantity: 5, transactionId: "scan-{$job->id}");

The transactionId is the idempotency key — pass a deterministic one derived from your own domain object. Retried jobs and duplicated queue messages are the norm, and dedup here is what stops you double-billing a customer for the same scan.

Check quota defensively even behind middleware. Middleware guards the request boundary; long-running jobs and console commands bypass it entirely. For anything executed asynchronously, call $usage->isWithinLimit() at the start of the job and bail with a InsufficientQuotaException equivalent rather than trusting the route guard.

Choose between the three payment paths deliberately:

Path Use when
$manager->driver('paystack')->charge(...) One provider, you control the choice, keys are per-tenant or global
$company->chargeVia('mpesa', ...) You want PayOrchestra "channel hint" routing (mpesa, card) and failover
$company->hostedPayment(...) → redirect to session_url You don't want to hold PCI scope or build a payment form at all

For most East African SaaS deployments, hostedPayment() is the pragmatic default — it sidesteps the "which provider does this customer's phone support" problem. Only drop to driver()->charge() when you need a specific channel (e.g. M-Pesa STK Push against a known phone number) or you're building a reconciliation/refund back-office.

Multi-channel checkout via config, not code. Expose a curated set through BILLING_ENABLED_PROVIDERS and let the frontend read GET /payments/options. This lets you turn off a flaky provider in production by editing env, without a deploy — genuinely valuable when a bank gateway is having a bad week.

Treat webhooks as notifications, not as the source of truth. This is the single most important integration pattern in this package:

  • Verified callbacks (HMAC signature or configured ?secret=) settle inline.
  • Unverified ones (M-Pesa, T-Kash, Co-op, Stanbic, NCBA, Pesapal) enqueue a job that re-queries the provider's status API and settles only on confirmation. That's billing.webhooks.confirm_unverified, on by default — leave it on.

Practical implication: a worker must be running or a chunk of your payments will sit in pending until someone notices. Queue health is now a payment-critical dependency.

Map events to your own notifications layer. Every state change fires a Laravel event; don't call SMS/email inline from billing code. Listen for PaymentSettled, SubscriptionCancelled, UsageThresholdReached, etc., and fan out to SMS (Africa's Talking), email, and your internal webhooks from listeners. This keeps the package swappable.

Proration on plan change: own the comms. The package handles proration math, but the customer-visible messaging ("you'll be charged KES X on the 1st") is yours. Snapshot the proration amount at change time in your own table if you display it anywhere — otherwise you'll be re-deriving it from package internals.

Testing: bind a fake driver in the container for feature tests, and use manual as the real integration point in staging — cash/bank/cheque settlement flows exercise the same invoice and subscription transitions without hitting a sandbox that's down. Test the "unverified webhook arrives, job re-queries, provider says FAILED" path explicitly; it's the path that loses money if broken.

Gotchas and Tips

All amounts are in minor units. base_price => 750000 is KES 7,500.00, addon_price => 2000 is KES 20.00. This will bite you on the first invoice if you're sloppy. Wrap your own Money/KES helper and never pass a raw float from a form.

Plan::create requires a manual ulid. Unlike most Laravel packages, it isn't auto-generated in the README example. Either always set Str::ulid()->toBase32() or verify the model boot hook exists — a plan row with a null ULID will fail opaquely later (route model binding, API responses).

Trial days appear in two places. trial_days on the Plan and ->trialDays(14) on the subscription builder. Decide which is authoritative for your business — plan-level is the sane default, since otherwise a customer can extend a trial by re-subscribing. Make the builder call conditional on the plan's own setting.

max_posts / max_guards are limits, not features. The limits map is a separate namespace from features. Don't assume hasFeature('max_guards') works — use the quota helpers (remainingQuota, usagePercentage) for the numeric entries.

Provider per-transaction caps are real and enforced silently. M-Pesa caps at 250k; amounts above get split into linked tranches settling one invoice. This means one logical payment can produce N provider records. Any reconciliation or "mark as paid" logic you write must key off the invoice, not a single payment row, or you'll mark invoices paid on the first tranche.

Webhook routes are public and the default middleware stack won't protect them. routes.middleware defaults to ['api', 'auth:sanctum'] for the API surface, but callbacks necessarily bypass auth. Your actual controls are the per-provider ip_allowlist, secret, and the re-query path. Configure all three where the provider supports them:

'webhooks' => [
    'confirm_unverified' => true,
    'providers' => [
        'mpesa' => ['ip_allowlist' => ['196.201.214.200', /* ... */]],
        'paystack' => ['secret' => env('PAYSTACK_SECRET')],
    ],
],

Register callback URLs before you go live, and make them publicly reachable over HTTPS. Every provider here (Safaricom especially) validates the callback URL at registration time. Local development requires a tunnel — plan for it rather than discovering it the morning of a demo.

Unsigned providers are not a bug, it's the ecosystem. M-Pesa Daraja callbacks carry no signature. That's exactly why the re-query confirmation path exists. If someone on your team disables confirm_unverified to "fix latency", you've built a free-money endpoint for anyone who can guess a payment reference. Add a code-owner rule or a test asserting the config is true.

Usage alert thresholds (80/90/100%) fire automatically — but only if you're listening. They're events. Wire up a listener early, or you'll only hear about quota exhaustion from angry support tickets.

Cache config/billing.php carefully. Provider credentials read from env() inside config are fine after config:cache, but any direct env() call in package code outside config files will silently return null in production. Set everything through config, and run php artisan config:cache in your deploy smoke test to catch it.

Maturity: treat this as a young dependency. Zero stars, zero dependents, a single maintainer, and a release date that's ahead of the current date — verify the tag you're installing actually resolves and pin it exactly ("moffhub/billing": "1.x.y"). Concretely:

  • Wrap PaymentManager and UsageService behind your own BillingService/MeteringService interfaces so a migration to Cashier + a hosted African gateway, or a fork, is a one-file change rather than a codebase-wide search-replace.
  • Write integration tests around the four transitions you cannot afford to get wrong: trial → active, payment settled, payment failed/abandoned, plan change with proration. Those tests also become your migration safety net.
  • Read the migration files before running them in production — check for indexes on billable_id + billable_type, provider_payment_id, and transaction_id (the dedup key). Missing indexes on those will only hurt at volume, which is the worst time to find out.
  • Have an escape hatch for the money path: if PayOrchestra or a driver misbehaves, the manual driver plus your own reconciliation report should keep revenue flowing.

Extension point worth knowing: all providers implement a common PaymentProviderInterface (charge, refund, getPaymentStatus, verifyWebhook). If you need a provider not in the list of 14, write one class against that interface and register the driver rather than forking the package — that's the cheapest long-term bet on an immature dependency.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
elmogy/larafast
xeviant/laravel-paystack
gorilladash/laravel-website-sdk
martialbe/flysystem-oss
chondal/3312client
jeroen-g/flickr
opscale-co/nova-genbi
itsjustvita/laravel-retention
tequilarapido/trackit
gregsphotography/site-builder-filament
cabaoctavio/laravel-query-log