Skip to content

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-php

Add 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.

config/services.php
'wajub' => [
  'key' => env('WAJUB_API_KEY'),
  'webhook_secret' => env('WAJUB_WEBHOOK_SECRET'),
],

Bind the client once as a singleton.

app/Providers/AppServiceProvider.php
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.

database/migrations/0001_01_01_000000_create_billing_tables.php
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.

The guard against charging twice
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.

app/Actions/Billing/ChargeInvoice.php
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.

app/Actions/Billing/StartSubscription.php
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().

app/Http/Controllers/WajubWebhookController.php
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.

routes/web.php
Route::post('/webhooks/wajub', WajubWebhookController::class)
  ->withoutMiddleware([VerifyCsrfToken::class]);

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.

app/Jobs/ApplyPaidInvoice.php
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.

app/Console/Commands/SendRenewalReminders.php
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.

app/Console/Commands/ProcessRenewals.php
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.

app/Console/Commands/ProcessDunning.php
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.

routes/console.php
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.

PhoneWhat it exercises
+237670000000Sign-up succeeds, subscription becomes active
+237670000001Renewal fails on insufficient funds, dunning engages
+237670000002The operator refuses, same path, different reason

To reach the renewal without waiting, move the period back and run the command by hand.

Forcing a renewal
php artisan tinker --execute="App\Models\Subscription::find(1)->update(['current_period_end' => now()->subDay()])"

php artisan billing:renew

Then forward the webhook to your machine so the invoice actually settles.

Local webhook delivery
wajub listen --forward-to localhost:8000/webhooks/wajub

What did you think of this content?