emeroid/laravel-billing-core
Driver-based, multi-gateway billing for Laravel with a fluent API for one-time payments and subscriptions. Supports Paystack and PayPal, plan swapping, grace-period cancellation, dunning via webhooks, events, and a Billable trait for your User model.
Laravel Billing Core is a robust, driver-based, multi-gateway billing package for Laravel. It provides a simple, fluent API to manage one-time payments, subscriptions, plan swapping, and dunning logic for your SaaS application.
Stop rebuilding billing logic for every project — this package is the plug-and-play foundation you need.
.env.subscribe, cancel, swapPlan).invoice.payment_failed webhooks to set past_due status.SubscriptionStarted, SubscriptionCancelled, etc.User model.composer require emeroid/laravel-billing-core
php artisan vendor:publish --provider="Emeroid\Billing\BillingServiceProvider" --tag="billing-config"
This creates
config/billing.php.
php artisan vendor:publish --provider="Emeroid\Billing\BillingServiceProvider" --tag="billing-migrations"
This adds the plans, subscriptions, and transactions tables.
php artisan migrate
.env File# --- BILLING CORE ---
BILLING_DEFAULT_DRIVER=paystack
BILLING_MODEL=\App\Models\User
# --- PAYSTACK ---
PAYSTACK_PUBLIC_KEY=pk_...
PAYSTACK_SECRET_KEY=sk_...
# --- PAYPAL ---
PAYPAL_CLIENT_ID=...
PAYPAL_SECRET=...
PAYPAL_MODE=sandbox
PAYPAL_WEBHOOK_ID=WH-...
Billable Trait// app/Models/User.php
use Emeroid\Billing\Traits\Billable;
use Illuminate{...};
class User extends Authenticatable
{
use Billable, HasFactory, Notifiable;
// ...
}
Before creating subscriptions, define your plans in the plans table — typically via a seeder:
// database/seeders/PlanSeeder.php
use Emeroid\Billing\Models\Plan;
use Illuminate\Database\Seeder;
class PlanSeeder extends Seeder
{
public function run(): void
{
Plan::create([
'name' => 'Pro Plan',
'slug' => 'pro-plan',
'amount' => 500000, // 5000 NGN (in kobo)
'interval' => 'monthly',
'paystack_plan_id' => 'PL_abc123',
'paypal_plan_id' => 'P-xyz456',
]);
Plan::create([
'name' => 'Business Plan',
'slug' => 'business-plan',
'amount' => 1000000, // 10000 NGN
'interval' => 'monthly',
'paystack_plan_id' => 'PL_def789',
'paypal_plan_id' => 'P-ghi123',
]);
}
}
use Emeroid\Billing\Facades\Billing;
use Illuminate\Http\Request;
class PaymentController
{
public function startPayment(Request $request)
{
$user = $request->user();
$amountInKobo = 50000; // 5000 NGN
try {
$payment = Billing::purchase($amountInKobo, $user->email, [
'user_id' => $user->id,
'currency' => 'NGN',
]);
return redirect()->away($payment['authorization_url']);
} catch (\Emeroid\Billing\Exceptions\PaymentInitializationFailedException $e) {
return back()->with('error', $e->getMessage());
}
}
}
use Emeroid\Billing\Facades\Billing;
use Emeroid\Billing\Models\Plan;
use Illuminate\Http\Request;
class SubscriptionController
{
public function startSubscription(Request $request)
{
$user = $request->user();
$plan = Plan::where('slug', 'pro-plan')->firstOrFail();
$gatewayPlanId = config('billing.default') === 'paypal'
? $plan->paypal_plan_id
: $plan->paystack_plan_id;
try {
$subscription = Billing::subscribe(
$gatewayPlanId,
$user->email,
[
'amount' => $plan->amount,
'user_id' => $user->id,
'currency' => 'NGN',
]
);
return redirect()->away($subscription['authorization_url']);
} catch (\Emeroid\Billing\Exceptions\PaymentInitializationFailedException $e) {
return back()->with('error', $e->getMessage());
}
}
}
After payment, users are redirected to your site.
The built-in CallbackController handles:
Billing::verifyTransaction(...)TransactionSuccessful, SubscriptionStarted)config/billing.php)All of this happens automatically.
Add these URLs to your gateway dashboards:
https://your-app.com/billing-webhooks/paystack
https://your-app.com/billing-webhooks/paypal
The package automatically handles:
charge.success → verifies payments
subscription.create → creates subscriptions
subscription.disable → triggers SubscriptionCancelled
Dunning events:
invoice.payment_failed (Paystack)BILLING.SUBSCRIPTION.PAYMENT.FAILED (PayPal)Billable Trait API$user = auth()->user();
// STATUS CHECKS
$user->isSubscribed();
$user->onGracePeriod();
$user->hasActiveSubscription();
$user->isSubscribedTo('pro-plan');
$user->isPastDue();
// MANAGEMENT
$subscription = $user->getSubscription('SUB_abc');
$user->cancelSubscription($subscription->gateway_subscription_id);
$user->swapPlan($subscription->gateway_subscription_id, 'business-plan');
$user->syncSubscription($subscription->gateway_subscription_id);
Listen for billing events in your EventServiceProvider:
// app/Providers/EventServiceProvider.php
use Emeroid\Billing\Events\{
TransactionSuccessful,
SubscriptionStarted,
SubscriptionCancelled,
SubscriptionPlanSwapped,
SubscriptionPaymentFailed
};
protected $listen = [
TransactionSuccessful::class => [
'App\Listeners\GrantAccessToProduct',
'App\Listeners\SendInvoiceEmail',
],
SubscriptionStarted::class => [
'App\Listeners\ActivateProFeatures',
],
SubscriptionCancelled::class => [
'App\Listeners\RevokeProFeaturesAtPeriodEnd',
],
SubscriptionPlanSwapped::class => [
'App\Listeners\HandlePlanSwap',
],
SubscriptionPaymentFailed::class => [
'App\Listeners\SendDunningEmail',
],
];
This document explains how to configure the Emeroid Billing package to correctly identify the billable entity when the customer’s payment gateway email does not directly match the billable model’s email field.
In many SaaS applications, a subscription belongs to a Team or Organization, even though the User making the payment uses their personal email.
By default, the AbstractDriver attempts to locate the Billable Model (e.g., Team) by matching the email directly on that model.
If your Billable Model does not store the customer’s email, you must implement a custom lookup strategy.
BillableLookup ClassCreate a lookup class that identifies the Billable Model (Team) through an associated User.
This method must have the exact signature:
public static function findByEmail(string $email): ?Model
app/Lookups/BillableLookup.php<?php
namespace App\Lookups;
use App\Models\User;
use App\Models\Team;
use Illuminate\Database\Eloquent\Model;
class BillableLookup
{
/**
* Custom strategy to find the billable model instance (Team) from an email.
*
* @param string $email The email address received from the payment gateway.
* @return Model|null The Billable Model instance (Team) or null if not found.
*/
public static function findByEmail(string $email): ?Model
{
// 1. Find the User by email.
$user = User::where('email', $email)->first();
if (!$user) {
return null;
}
// 2. Return the Billable model (Team) associated with this User.
// Example: Team is linked to User via the 'owner_id' foreign key.
return Team::where('owner_id', $user->id)->first();
}
}
Register the custom lookup method inside config/billing.php under your gateway settings:
// File: config/billing.php
'email_lookup' => [
\App\Lookups\BillableLookup::class,
'findByEmail'
],
// ... rest of your config
The Emeroid\Billing\Drivers\AbstractDriver is designed to check for your custom lookup first:
// Excerpt from Emeroid\Billing\Drivers\AbstractDriver.php
protected function findBillableByEmail(string $email): ?Model
{
// 1. Check for a custom lookup strategy defined in the billing config.
$customLookup = config('billing.email_lookup');
if (is_callable($customLookup)) {
// Use the custom callable if defined.
return call_user_func($customLookup, $email);
}
// 2. Fallback: Check the billable model directly.
$modelClass = $this->billableModelClass();
return $modelClass::where('email', $email)->first();
}
This customization ensures reliable billing behavior even when your architecture involves multi-layer ownership structures (e.g., users owning teams). The billing package will correctly identify the billable entity through your defined strategy, preventing mismatches and failed invoice processing.
composer test
Please see CONTRIBUTING.md for details.
If you discover any security-related issues, please email 📧 threalyongbug@gmail.com instead of using the issue tracker.
Licensed under the MIT License (MIT). See the License File for more information.
This project is free and open-source. If it helps you build your business, please consider supporting its development.
Sponsor @emeroid on GitHub.
How can I help you explore Laravel packages today?