Laravel recurring billing
Subscriptions built from plain payments in Laravel, with renewals and dunning.
This recipe builds subscriptions from plain payments in Laravel: three migrations, one action, one webhook controller and three scheduled commands. The reasoning behind each decision is in Recurring billing.
The invoice route, and where it stands
Invoices carry an is_recurring flag which looks like a shorter path. Read
Recurring invoices first: each cycle the invoice is copied into a
new draft that still has to be sent and paid, and nothing is charged. What follows is the
architecture that collects each renewal.
The constraint that shapes the code
Every Mobile Money charge is confirmed by the customer, on their handset, with their PIN. A renewal is therefore a prompt on somebody's phone, not a silent debit, and it fails if they are asleep or out of credit.
That is why this recipe has three commands and not one. Reminding comes before charging, and dunning comes after.
1. Install and configure
npm install wajub/wajub-phpAdd the credentials to config/services.php so nothing reads env() outside a config file, which is
what keeps php artisan config:cache from blanking them in production.
'wajub' => [
'key' => env('WAJUB_API_KEY'),
'webhook_secret' => env('WAJUB_WEBHOOK_SECRET'),
],Bind the client once as a singleton.
use Wajub\Wajub;
public function register(): void
{
$this->app->singleton(Wajub::class, fn () => new Wajub([
'api_key' => config('services.wajub.key'),
'webhook_secret' => config('services.wajub.webhook_secret'),
]));
}2. The schema
A plan is a price and a rhythm. A subscription is one customer on one plan. An invoice is one cycle, and it is where every charge attempt is recorded.
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->decimal('amount', 12, 2);
$table->char('currency', 3)->default('XAF');
$table->enum('interval', ['weekly', 'monthly', 'yearly']);
$table->timestamps();
});
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('plan_id')->constrained();
$table->string('status')->default('incomplete');
$table->string('phone', 32);
$table->string('email')->nullable();
$table->timestamp('current_period_start');
$table->timestamp('current_period_end');
$table->boolean('cancel_at_period_end')->default(false);
$table->timestamp('cancelled_at')->nullable();
$table->timestamps();
});
Schema::create('billing_invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id')->constrained();
$table->string('kind');
$table->decimal('amount', 12, 2);
$table->char('currency', 3);
$table->string('status')->default('open');
$table->string('payment_id', 64)->nullable()->unique();
$table->unsignedInteger('attempts')->default(0);
$table->timestamp('period_end');
$table->timestamp('due_at');
$table->timestamp('paid_at')->nullable();
$table->timestamps();
});One index does more than all the application code you would otherwise write to replace it.
DB::statement(
'CREATE UNIQUE INDEX billing_invoices_open_per_sub
ON billing_invoices (subscription_id)
WHERE status = \'open\'',
);A subscription can now hold at most one open invoice. If the scheduler fires twice, or two workers pick up the same subscription, the second insert throws and you skip it. The database says no, rather than your code checking first and racing anyway.
3. The only place the API is called
Every charge in the system goes through this action: the first payment, every renewal, every dunning retry.
It makes two calls. payments->create() opens the payment for the invoice, and
payments->process() sends it to the customer's phone: the mobile channel lets Wajub find the
operator from the number, so the prompt goes out without anyone opening a page. Created alone, a
payment reaches no handset and expires 24 hours later.
namespace App\Actions\Billing;
use App\Models\BillingInvoice;
use Wajub\Objects\Payment;
use Wajub\RequestOptions;
use Wajub\Wajub;
class ChargeInvoice
{
public function __construct(private readonly Wajub $wajub)
{
}
public function handle(BillingInvoice $invoice): Payment
{
$subscription = $invoice->subscription;
$plan = $subscription->plan;
$attempt = $invoice->attempts + 1;
$key = "sub-{$subscription->id}-inv-{$invoice->id}-attempt-{$attempt}";
$payment = $this->wajub->payments->create([
'amount' => (float) $invoice->amount,
'currency' => $invoice->currency,
'customer' => [
'phone' => $subscription->phone,
'email' => $subscription->email,
],
'description' => "{$plan->name}, ".$invoice->period_end->translatedFormat('F Y'),
'reference' => "sub-{$subscription->id}-inv-{$invoice->id}",
], new RequestOptions(idempotencyKey: $key));
$invoice->update([
'payment_id' => $payment->id,
'attempts' => $attempt,
]);
return $this->wajub->payments->process($payment->id, [
'channel' => 'mobile',
'data' => [
'phone' => $subscription->phone,
],
], new RequestOptions(idempotencyKey: "{$key}-charge"));
}
}Two details are load-bearing.
The idempotency key carries the attempt number, and the charge takes its own key derived from it. Without the attempt number, a second attempt on the same invoice returns the first attempt's failed payment instead of opening a new one, and the subscription is stuck forever.
The description is what the customer reads on the prompt on their phone. Premium, octobre 2026
gets confirmed. A bare merchant name gets declined.
4. Signing up
Signing up writes two rows and then charges. Nothing is active until the money is confirmed, which is
why the subscription starts incomplete.
public function handle(User $user, Plan $plan, string $phone): Payment
{
$subscription = DB::transaction(function () use ($user, $plan, $phone) {
$subscription = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'status' => 'incomplete',
'phone' => $phone,
'email' => $user->email,
'current_period_start' => now(),
'current_period_end' => $plan->addInterval(now()),
]);
BillingInvoice::create([
'subscription_id' => $subscription->id,
'kind' => 'first',
'amount' => $plan->amount,
'currency' => $plan->currency,
'period_end' => $subscription->current_period_end,
'due_at' => now(),
]);
return $subscription;
});
return $this->chargeInvoice->handle($subscription->invoices()->first());
}When this returns, the prompt is already on the customer's phone. Show a page asking them to confirm it, and your webhook takes it from there.
5. The webhook controller
This is the only place a subscription becomes active. Verification needs the raw request body, which
in Laravel is $request->getContent() and never $request->all().
namespace App\Http\Controllers;
use App\Jobs\ApplyPaidInvoice;
use App\Models\BillingInvoice;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Wajub\Exception\WebhookSignatureVerificationError;
use Wajub\Wajub;
class WajubWebhookController extends Controller
{
public function __construct(private readonly Wajub $wajub)
{
}
public function __invoke(Request $request): Response
{
try {
$event = $this->wajub->webhooks->constructEvent(
$request->getContent(),
$request->header('X-Wajub-Signature', ''),
$request->header('X-Wajub-Timestamp', ''),
);
} catch (WebhookSignatureVerificationError) {
return response('', 400);
}
if ($event['event'] === 'payment.succeeded') {
ApplyPaidInvoice::dispatch($event['data']['id']);
}
if (in_array($event['event'], ['payment.failed', 'payment.expired', 'payment.cancelled'], true)) {
BillingInvoice::where('payment_id', $event['data']['id'])
->update(['status' => 'past_due']);
}
return response('', 200);
}
}Register the route outside the web group so no CSRF middleware runs on it.
Route::post('/webhooks/wajub', WajubWebhookController::class)
->withoutMiddleware([VerifyCsrfToken::class]);Ten seconds, then it counts as failed
A delivery that does not get a 2xx within ten seconds counts as failed and is redelivered for up
to 72 hours. Dispatching a job and returning, as above, is what keeps a slow
mailer from turning one payment into a string of deliveries.
6. Applying a successful charge
The job branches on the invoice's kind, never on the subscription's current status. The same job
may run twice on a retry, and a status you already changed is not a reliable input.
public function handle(Wajub $wajub): void
{
$payment = $wajub->payments->retrieve($this->paymentId);
if ($payment->status !== 'succeeded') {
return;
}
$invoice = BillingInvoice::where('payment_id', $this->paymentId)->first();
if ($invoice === null || $invoice->status === 'paid') {
return;
}
$invoice->update(['status' => 'paid', 'paid_at' => now()]);
$subscription = $invoice->subscription;
$plan = $subscription->plan;
if ($invoice->kind === 'first') {
$subscription->update([
'status' => 'active',
'current_period_start' => now(),
'current_period_end' => $plan->addInterval(now()),
]);
Mail::to($subscription->email)->send(new SubscriptionStarted($subscription));
return;
}
$subscription->update([
'current_period_start' => $subscription->current_period_end,
'current_period_end' => $plan->addInterval($subscription->current_period_end),
]);
Mail::to($subscription->email)->send(new RenewalConfirmed($subscription, $invoice));
}A renewal extends from current_period_end, not from today. That keeps a customer who pays two days
late on the same billing date instead of drifting further every month.
The $invoice->status === 'paid' check is the idempotency guard. Replay the same event as often as
you like: the second run stops there.
7. Three commands
Each one does a single thing, and the scheduler runs them in order every morning.
public function handle(): int
{
$due = Subscription::query()
->where('status', 'active')
->where('cancel_at_period_end', false)
->whereBetween('current_period_end', [now()->addDays(2), now()->addDays(3)])
->get();
foreach ($due as $subscription) {
Mail::to($subscription->email)
->send(new RenewalUpcoming($subscription, $subscription->plan));
}
return self::SUCCESS;
}The renewal command opens the next invoice and charges it. Catching the unique violation is the whole
safety mechanism: a duplicate run inserts nothing and moves on. A WajubError from the charge, the
operator refusing on the spot for instance, leaves the invoice past_due: nothing reached the phone,
and dunning takes it from there.
public function handle(ChargeInvoice $chargeInvoice): int
{
$due = Subscription::query()
->where('status', 'active')
->where('current_period_end', '<=', now()->endOfDay())
->get();
foreach ($due as $subscription) {
if ($subscription->cancel_at_period_end) {
$subscription->update(['status' => 'cancelled']);
continue;
}
$plan = $subscription->plan;
try {
$invoice = BillingInvoice::create([
'subscription_id' => $subscription->id,
'kind' => 'renewal',
'amount' => $plan->amount,
'currency' => $plan->currency,
'period_end' => $plan->addInterval($subscription->current_period_end),
'due_at' => $subscription->current_period_end,
]);
} catch (UniqueConstraintViolationException) {
continue;
}
try {
$chargeInvoice->handle($invoice);
} catch (WajubError $e) {
$invoice->update(['status' => 'past_due']);
Log::error('Renewal charge failed to start', [
'invoice' => $invoice->id,
'error' => $e->getMessage(),
]);
}
}
return self::SUCCESS;
}Dunning retries a past_due invoice on days one, three and five, then gives up on day seven. Three
attempts over a week is a reasonable default: each attempt is another prompt on a phone, and more
than that simply annoys somebody whose wallet is empty.
public function handle(ChargeInvoice $chargeInvoice): int
{
$retryDays = [1, 3, 5];
$giveUpDay = 7;
foreach (BillingInvoice::where('status', 'past_due')->get() as $invoice) {
$age = (int) $invoice->due_at->diffInDays(now());
$subscription = $invoice->subscription;
if ($age >= $giveUpDay) {
$subscription->update(['status' => 'unpaid']);
$invoice->update(['status' => 'uncollectible']);
Mail::to($subscription->email)->send(new SubscriptionSuspended($subscription));
continue;
}
if (! in_array($age, $retryDays, true)) {
continue;
}
if ($invoice->attempts > array_search($age, $retryDays, true) + 1) {
continue;
}
try {
$chargeInvoice->handle($invoice);
} catch (WajubError) {
continue;
}
Mail::to($subscription->email)->send(new RetryingCharge($subscription, $invoice));
}
return self::SUCCESS;
}The attempts check stops the command sending two prompts when it runs twice on the same day. Every
retry goes back through ChargeInvoice, so it gets a fresh idempotency key and opens a genuinely new
payment.
Schedule::command('billing:remind')->dailyAt('06:00');
Schedule::command('billing:renew')->dailyAt('06:30');
Schedule::command('billing:dun')->dailyAt('07:00');8. Test a full cycle without waiting a month
Sandbox lets you walk every branch in an afternoon. The last six digits of the phone number decide what the operator does.
| Phone | What it exercises |
|---|---|
+237670000000 | Sign-up succeeds, subscription becomes active |
+237670000001 | Renewal fails on insufficient funds, dunning engages |
+237670000002 | The operator refuses, same path, different reason |
To reach the renewal without waiting, move the period back and run the command by hand.
php artisan tinker --execute="App\Models\Subscription::find(1)->update(['current_period_end' => now()->subDay()])"
php artisan billing:renewThen forward the webhook to your machine so the invoice actually settles.
wajub listen --forward-to localhost:8000/webhooks/wajubRelated pages
- Recurring billingThe architecture this recipe implements, and why each piece exists.
- Recurring invoicesThe other route, and exactly where it stands today.
- PHP SDKEvery resource, option and exception.
- Express webhook handlerDeduplication and queueing, in more depth than step 5.
- IdempotencyWhy the key carries the attempt number.