Feature-based subscription billing for Laravel with first-class African payment provider support.
Define plans, gate features, track usage, accept payments via PayOrchestra (orchestration backbone with smart routing, failover, and reconciliation) or directly through M-Pesa, Airtel Money, KCB, Equity, Co-op Bank, Stanbic, NCBA, IntaSend, Paystack, Flutterwave, or Pesapal — all through one package.
Laravel Cashier is Stripe-only. Spark is closed-source. Neither handles M-Pesa or feature-based gating.
This package gives you:
composer require moffhub/billing
php artisan vendor:publish --tag=billing-config
php artisan vendor:publish --tag=billing-migrations
php artisan migrate
Add Billable to whichever model represents your paying entity — User, Team, Company, Organization:
use Moffhub\Billing\Traits\Billable;
class Company extends Model
{
use Billable;
}
php artisan billing:sync-plans --seed
Or create them programmatically:
use Moffhub\Billing\Models\Plan;
use Moffhub\Billing\Models\Feature;
// Register features
Feature::create([
'slug' => 'ocr_scanning',
'name' => 'OCR Scanning',
'type' => 'metered', // boolean | metered | consumable
'is_addon' => true,
'addon_price' => 2000, // KES 20.00 (in cents)
]);
// Create a plan
Plan::create([
'ulid' => Str::ulid()->toBase32(),
'name' => 'Standard',
'slug' => 'standard',
'base_price' => 750000, // KES 7,500.00
'billing_cycle' => 'monthly',
'trial_days' => 14,
'features' => ['gatebook', 'incidents', 'shifts', 'analytics_reports'],
'limits' => [
'max_posts' => 10,
'max_guards' => 25,
'ocr_scanning' => 100,
],
]);
// Subscribe with a 14-day trial
$company->subscribe('standard')
->trialDays(14)
->provider('mpesa')
->create();
// Check subscription status
$company->subscribed(); // true
$company->onPlan('standard'); // true
$company->onTrial(); // true
Middleware — protect routes by feature or plan:
// Require a specific feature (AND logic)
Route::middleware(['feature:ocr_scanning'])->group(function () {
Route::post('ocr/scan', ScanController::class);
});
// Require one of several plans (OR logic)
Route::middleware(['plan:professional,enterprise'])->group(function () {
Route::get('analytics/advanced', AnalyticsController::class);
});
// Combine feature access + usage limit enforcement
Route::middleware(['feature:ocr_scanning', 'usage:ocr_scanning'])->group(function () {
Route::post('ocr/scan', ScanController::class);
});
Blade — show/hide UI elements:
@feature('shifts')
<a href="/shifts">Shift Management</a>
@else
<a href="/upgrade">Upgrade to unlock Shifts</a>
@endfeature
@plan('professional')
<span class="badge">PRO</span>
@endplan
In code — check programmatically:
if ($company->hasFeature('ocr_scanning')) {
// perform scan
}
$remaining = $company->remainingQuota('ocr_scanning'); // 58
$percentage = $company->usagePercentage('ocr_scanning'); // 0.42
use Moffhub\Billing\Services\UsageService;
$usage = app(UsageService::class);
// Record a usage event
$usage->record($company, 'ocr_scanning');
// Record with quantity and deduplication
$usage->record($company, 'ocr_scanning', quantity: 5, transactionId: 'scan-abc-123');
// Check limits
$usage->isWithinLimit($company, 'ocr_scanning'); // true/false
$usage->getUsage($company, 'ocr_scanning'); // 42
Usage alerts fire automatically at configurable thresholds (80%, 90%, 100%).
use Moffhub\Billing\PaymentManager;
$manager = app(PaymentManager::class);
// Charge via the default provider
$result = $manager->driver()->charge(750000, 'KES', [
'phone' => '254712345678', // for M-Pesa STK Push
]);
// Or specify a provider
$result = $manager->driver('paystack')->charge(750000, 'KES', [
'email' => 'customer@example.com',
]);
// Collect via KCB BUNI (M-Pesa, Airtel, T-Kash, VOOMA, or bank)
$result = $manager->driver('kcb')->charge(50000, 'KES', [
'phone' => '0712345678',
'payment_channel' => 'mpesa', // mpesa, airtel, tkash, vooma, bank
'reference' => 'INV-001',
]);
// Collect via Airtel Money
$result = $manager->driver('airtel')->charge(50000, 'KES', [
'phone' => '0733123456',
'reference' => 'INV-002',
]);
// Transfer via Co-op Bank PesaLink (to any Kenyan bank)
$result = $manager->driver('coopbank')->charge(100000, 'KES', [
'destination_account' => '0011547896523',
'bank_code' => '01', // KCB
'transfer_type' => 'pesalink',
]);
// Route through PayOrchestra — channel hint picks the right connector
$result = $company->chargeVia('mpesa', 750000, 'KES', [
'phone' => '254712345678',
'reference' => 'INV-001',
]);
$result = $company->chargeVia('card', 750000, 'KES', [
'email' => 'customer@example.com',
'reference' => 'INV-001',
]);
// PayOrchestra hosted checkout — redirect the payer
$session = $company->hostedPayment(750000, 'KES', [
'description' => 'Invoice #INV-001',
'success_url' => 'https://app.example.com/payment/success',
'cancel_url' => 'https://app.example.com/payment/cancel',
]);
return redirect($session['session_url']);
use Moffhub\Billing\Models\Invoice;
// Via API
POST /api/billing/invoices
{
"items": [
{"description": "Standard Plan - March 2026", "unit_price": 750000},
{"description": "OCR Add-on - 42 scans", "unit_price": 2100, "feature_slug": "ocr_scanning"}
],
"tax_rate": 16.0
}
// Tax is calculated automatically (VAT 16% default for Kenya)
The package ships with a full REST API. All endpoints are documented in docs/API.md.
| Resource | Endpoints | Description |
|---|---|---|
| Plans | 5 | List, show, create, update, delete |
| Features | 6 | List, list add-ons, show, create, update, delete |
| Subscriptions | 8 | Subscribe, current, change plan, cancel, pause, resume |
| Add-ons | 3 | List, enable, disable |
| Usage | 3 | Summary, detail, record |
| Payments | 4 | List, initiate, show, refund |
| Invoices | 6 | List, create, show, send, void, mark-paid |
| Webhooks | 12 | M-Pesa, Paystack, Flutterwave, Pesapal, Airtel, KCB, Jenga, Co-op, Stanbic, NCBA, IntaSend, PayOrchestra callbacks |
Routes are configurable:
// config/billing.php
'routes' => [
'enabled' => true,
'prefix' => 'api/billing',
'middleware' => ['api', 'auth:sanctum'],
'rate_limit' => 60,
],
| Provider | Driver | Payment Methods |
|---|---|---|
| PayOrchestra ⭐ | payorchestra |
Multi-channel via backbone — routes to M-Pesa, cards, bank transfers, etc. with smart routing, failover, reconciliation, hosted checkout |
| M-Pesa | mpesa |
STK Push, C2B, B2C |
| Airtel Money | airtel |
C2B collections, B2C disbursements |
| KCB BUNI | kcb |
M-Pesa, Airtel, T-Kash, VOOMA, bank (multi-channel) |
| Equity Jenga | jenga |
Cards, mobile money, bank transfers |
| Co-op Bank | coopbank |
PesaLink (any bank), internal transfers, balance queries |
| Stanbic Bank | stanbic |
STK Push, mobile money, bank transfers |
| NCBA | ncba |
PesaLink, IPN Push |
| IntaSend | intasend |
M-Pesa STK Push, cards, bank, PesaLink |
| Paystack | paystack |
Cards, bank, mobile money |
| Flutterwave | flutterwave |
Cards, M-Pesa, MTN MoMo, bank |
| Pesapal | pesapal |
Cards, M-Pesa, Airtel Money |
| Manual | manual |
Cash, bank transfer, cheque |
Providers without direct APIs (Telkom T-Kash, Family Bank, DTB) can be accessed through KCB BUNI, Pesapal, or Flutterwave. See docs/PROVIDERS.md for full integration guides.
All providers implement PaymentProviderInterface:
interface PaymentProviderInterface
{
public function charge(int $amount, string $currency, array $options = []): array;
public function refund(string $providerPaymentId, ?int $amount = null, array $options = []): array;
public function getPaymentStatus(string $providerPaymentId): string;
public function verifyWebhook(Request $request): bool;
public function parseWebhook(Request $request): array;
public function isConfigured(): bool;
public function getName(): string;
}
Add your own provider:
class MyProvider extends BasePaymentProvider
{
public function charge(int $amount, string $currency, array $options = []): array
{
// Your implementation
return ['success' => true, 'provider_payment_id' => '...', 'status' => 'completed', ...];
}
public function getName(): string { return 'my-provider'; }
public function isConfigured(): bool { return true; }
}
Every state change fires a Laravel event so you can integrate with your SMS, email, analytics, or approval workflows.
| Event | Fired When | Payload |
|---|---|---|
SubscriptionCreated |
New subscription | $subscription |
SubscriptionCancelled |
Subscription cancelled | $subscription, $immediately |
SubscriptionRenewed |
Subscription renewed | $subscription |
PlanChanged |
Plan upgrade/downgrade | $subscription, $oldPlan, $newPlan |
PaymentReceived |
Successful payment | $payment |
PaymentFailed |
Failed payment | $payment, $reason |
UsageLimitApproaching |
Usage hits 80/90/100% | $billable, $featureSlug, $percentage |
FeatureAccessDenied |
Unauthorized feature access | $billable, $featureSlug, $reason |
Example listener:
// In EventServiceProvider
protected $listen = [
PaymentReceived::class => [SendPaymentReceiptSms::class],
UsageLimitApproaching::class => [SendUsageWarningNotification::class],
];
Publish the config file:
php artisan vendor:publish --tag=billing-config
Key settings:
return [
'currency' => 'KES', // Default currency (ISO 4217)
'default_provider' => 'mpesa', // Default payment provider
'billable_model' => App\Models\Company::class,
'tax' => [
'enabled' => true,
'default_rate' => 16.0, // Kenya VAT
],
'features' => [
'cache_ttl' => 300, // Feature access cache (seconds)
],
'usage' => [
'alert_thresholds' => [80, 90, 100], // Fire events at these %
'allow_overage' => false, // Hard-stop or allow over-limit
],
'subscriptions' => [
'grace_period_days' => 7, // Days before cancelling past-due
'dunning_schedule' => [1, 3, 7], // Retry failed charges on these days
'allow_pause' => true,
],
'providers' => [
'payorchestra' => [
'base_url' => env('PAYORCHESTRA_URL', 'https://backbone.payorchestra.com'),
'api_key' => env('PAYORCHESTRA_API_KEY'),
'org_id' => env('PAYORCHESTRA_ORG_ID'),
'webhook_secret' => env('PAYORCHESTRA_WEBHOOK_SECRET'),
],
'mpesa' => [
'consumer_key' => env('MPESA_CONSUMER_KEY'),
'consumer_secret' => env('MPESA_CONSUMER_SECRET'),
'shortcode' => env('MPESA_SHORTCODE'),
// ...
],
// airtel, kcb, jenga, coopbank, stanbic, ncba, intasend,
// paystack, flutterwave, pesapal, manual
],
];
# Seed default plans and features
php artisan billing:sync-plans --seed
# List current plans and features
php artisan billing:sync-plans
# Health check — providers, subscriptions, config
php artisan billing:health
See docs/ARCHITECTURE.md for the full code map, entity relationships, data flow diagrams, and event map.
Key design decisions:
Billable trait works on any Eloquent model via morphMany. Your billing entity can be User, Team, Company, or Organization.hasFeature('shifts')) not plan (onPlan('professional')). This means plan restructuring doesn't break your code.transaction_id on usage events prevents double-counting (idempotent recording).PaymentManager extends Laravel's Manager class. Same driver pattern as Mail, Queue, Cache. Add providers without changing core code.cd packages/moffhub/billing
composer install
vendor/bin/phpunit
716 tests, 1,840 assertions covering:
This package is part of the Moffhub Laravel package suite:
| Package | Purpose | Integration |
|---|---|---|
| moffhub/billing | Subscriptions, feature gating, payments | Core |
| moffhub/maker-checker | Approval workflows | Gate high-value billing actions (refunds, plan changes) |
| moffhub/sms-handler | Multi-provider SMS | Send payment receipts, usage warnings, invoice reminders |
| moffhub/ussd | USSD menus | "Check balance", "Upgrade plan" via USSD |
The packages are fully decoupled — billing fires events, your app wires them to SMS, approvals, or USSD as needed.
MIT
How can I help you explore Laravel packages today?