Ditching PAYG Subscriptions for a Pre-Paid Credit System in Laravel
Pay-As-You-Go billing sounds ideal: users pay only for what they use, the pricing feels fair, and Stripe handles the metering for you. In practice it has a nasty flaw — you charge at the end of the cycle, but the card can be empty by then.
Users run up $71 of usage. Their card is maxed out. You're left holding the bill.
Pre-paid credits flip this entirely. No credits = no action. The money is already in your account before the service is delivered. This post covers how to build a solid pre-paid credit system in Laravel using Cashier — including the edge cases that will bite you if you skip them (race conditions, idempotency, webhook double-firing), and one specific mistake to avoid when migrating away from metered subscriptions.
Simple:
Two migrations. The first adds credit fields to the users table:
Schema::table('users', function (Blueprint $table) {
$table->integer('credits_balance')->default(0)->after('pm_last_four');
$table->boolean('auto_recharge_enabled')->default(false)->after('credits_balance');
$table->integer('auto_recharge_threshold')->default(50)->after('auto_recharge_enabled');
});
The second creates a credit_transactions table — but only for purchases and auto-recharges, not individual action deductions. But if you don't store action deduction in your base app (like pdf generation, you don't store the result in the DB, you can add it here)
Schema::create('credit_transactions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->integer('amount'); // positive = added, negative = (not used)
$table->string('description');
$table->string('stripe_payment_intent_id')->nullable()->index();
$table->timestamps();
});
All credit logic lives in one place:
class CreditService
{
public const CREDITS_PER_EXPORT = 1; // lightweight: CSV, quick summary
public const CREDITS_PER_REPORT = 10; // heavyweight: full PDF report
public const STARTER_CREDITS = 5; // free credits on signup
public const AUTO_RECHARGE_THRESHOLD = 10;
public const PACK_CREDITS = 100;
public const PACK_PRICE_CENTS = 1000; // $10
public function hasCredits(User $user, int $amount = 1): bool
{
return $user->credits_balance >= $amount;
}
public function deductCredits(User $user, int $amount): bool
{
// Atomic check+decrement — prevents race conditions when multiple
// jobs run concurrently (e.g. a user requesting 5 videos at once)
$affected = User::where('id', $user->id)
->where('credits_balance', '>=', $amount)
->decrement('credits_balance', $amount);
if ($affected === 0) {
return false;
}
$user->credits_balance -= $amount;
return true;
}
public function addCredits(
User $user,
int $amount,
string $description,
?string $stripePaymentIntentId = null,
): CreditTransaction {
// Idempotency: if this payment intent was already credited, skip
if ($stripePaymentIntentId) {
$existing = CreditTransaction::where('stripe_payment_intent_id', $stripePaymentIntentId)->first();
if ($existing) {
return $existing;
}
}
return DB::transaction(function () use ($user, $amount, $description, $stripePaymentIntentId) {
$user->increment('credits_balance', $amount);
return CreditTransaction::create([
'user_id' => $user->id,
'amount' => $amount,
'description' => $description,
'stripe_payment_intent_id' => $stripePaymentIntentId,
]);
});
}
public function grantStarterCredits(User $user): void
{
if ($user->creditTransactions()->exists()) {
return; // already granted
}
$this->addCredits($user, self::STARTER_CREDITS, 'Welcome bonus credits');
}
}
A few things worth calling out:
The deductCredits method is a single atomic SQL query. The naive implementation would read the balance, check it, then decrement — two separate round trips. Imagine a user with 15 credits triggering 3 report jobs simultaneously. All 3 read 15 >= 10, all 3 pass, all 3 decrement — leaving the user at -15 credits and 3 free reports. The WHERE credits_balance >= amount condition makes the check and decrement inseparable at the database level. If it affects 0 rows, someone else got there first.
The addCredits method is idempotent. Stripe webhooks are delivered "at least once". If checkout.session.completed fires twice for the same payment, the idempotency check on stripe_payment_intent_id prevents double-crediting. This is easy to miss — without it your first test purchase will give you double credits and you'll only notice when a real user reports it 😅
No need to manage a Stripe product/price ID in config. With price_data, Stripe creates the price inline:
public function purchasePack()
{
$user = Auth::user();
return $user->checkout([
[
'price_data' => [
'currency' => 'usd',
'unit_amount' => CreditService::PACK_PRICE_CENTS,
'product_data' => ['name' => '100 Credits'],
],
'quantity' => 1,
],
], [
'success_url' => route('billing') . '?credits_purchased=1',
'cancel_url' => route('billing'),
'payment_intent_data' => [
'setup_future_usage' => 'off_session', // saves the card for auto-recharge
'metadata' => [
'type' => 'credit_pack',
'user_id' => (string) $user->id,
'credits' => (string) CreditService::PACK_CREDITS,
],
],
'metadata' => [
'type' => 'credit_pack',
'user_id' => (string) $user->id,
'credits' => (string) CreditService::PACK_CREDITS,
],
]);
}
setup_future_usage: off_session is the key — it tells Stripe to save the payment method for future charges without requiring the user to be present. That's what powers auto-recharge.
Two events matter: checkout.session.completed (manual purchase) and payment_intent.succeeded (auto-recharge).
class StripeEventListener
{
public function handle(WebhookReceived $event): void
{
match ($event->payload['type'] ?? null) {
'checkout.session.completed' => $this->handleCheckoutCompleted($event->payload),
'payment_intent.succeeded' => $this->handlePaymentIntentSucceeded($event->payload),
default => null,
};
}
private function handleCheckoutCompleted(array $payload): void
{
$session = $payload['data']['object'] ?? [];
if (($session['metadata']['type'] ?? null) !== 'credit_pack') {
return;
}
$user = User::find($session['metadata']['user_id'] ?? null);
if (! $user) return;
$this->credits->addCredits(
user: $user,
amount: (int) ($session['metadata']['credits'] ?? 0),
description: 'Credit pack purchase',
stripePaymentIntentId: $session['payment_intent'] ?? null,
);
$this->syncPaymentMethod($user, $session);
// Enable auto-recharge on first purchase
if (! $user->auto_recharge_enabled) {
$user->update(['auto_recharge_enabled' => true]);
}
}
private function handlePaymentIntentSucceeded(array $payload): void
{
$intent = $payload['data']['object'] ?? [];
// Only handle auto-recharge intents, not Checkout ones
if (($intent['metadata']['type'] ?? null) !== 'auto_recharge') {
return;
}
$user = User::find($intent['metadata']['user_id'] ?? null);
if (! $user) return;
$this->credits->addCredits(
user: $user,
amount: (int) ($intent['metadata']['credits'] ?? 0),
description: 'Auto-recharge',
stripePaymentIntentId: $intent['id'] ?? null,
);
}
private function syncPaymentMethod(User $user, array $session): void
{
try {
// The session payload doesn't include the PM directly — expand the PI
$paymentMethodId = $session['payment_method'] ?? null;
if (! $paymentMethodId && isset($session['payment_intent'])) {
$pi = Cashier::stripe()->paymentIntents->retrieve($session['payment_intent']);
$paymentMethodId = $pi->payment_method ?? null;
}
if ($paymentMethodId) {
$user->updateDefaultPaymentMethod($paymentMethodId);
}
} catch (\Throwable $e) {
Log::warning('stripe.sync_payment_method_failed', ['error' => $e->getMessage()]);
}
}
}
One gotcha: the checkout.session.completed payload does not include payment_method directly. You have to retrieve the PaymentIntent from Stripe to get it. I missed this and spent a while wondering why auto-recharge never fired — pm_type was null on every user.
After each credit deduction, check if the balance dropped below the threshold:
public function checkAndTriggerAutoRecharge(User $user): void
{
if (! $user->auto_recharge_enabled) return;
if ($user->credits_balance >= $user->auto_recharge_threshold) return;
if ($this->hasPendingRecharge($user)) return; // debounce
$this->triggerAutoRecharge($user);
}
public function triggerAutoRecharge(User $user): void
{
if (! $user->stripe_id) return;
// defaultPaymentMethod() reads invoice_settings.default_payment_method.
// Fall back to paymentMethods()->first() if no explicit default is set —
// which happens after Checkout when syncPaymentMethod hasn't run yet.
$method = $user->defaultPaymentMethod() ?? $user->paymentMethods()->first();
if (! $method) return;
Cashier::stripe()->paymentIntents->create([
'amount' => self::PACK_PRICE_CENTS,
'currency' => config('cashier.currency', 'usd'),
'customer' => $user->stripe_id,
'payment_method' => $method->id,
'confirm' => true,
'off_session' => true,
'metadata' => [
'type' => 'auto_recharge',
'user_id' => (string) $user->id,
'credits' => (string) self::PACK_CREDITS,
],
]);
}
When the PaymentIntent succeeds, Stripe sends payment_intent.succeeded, the listener picks it up, and addCredits runs. The user never hits a wall mid-workflow.
cancelNow() for metered subscriptions)If you're migrating away from Stripe metered billing, here's a mistake worth knowing about before you make it. When cancelling existing PAYG subscribers, the intuitive approach looks like this:
// ❌ WRONG — creates a separate invoice that doesn't capture metered usage
$invoice = Cashier::stripe()->invoices->create([
'customer' => $user->stripe_id,
'subscription' => $subscription->stripe_id,
]);
Cashier::stripe()->invoices->finalizeInvoice($invoice->id);
$subscription->cancelNow(); // too late, metered usage is already detached
None of the metered usage gets billed. The correct way to cancel a metered subscription with a final invoice is one atomic call:
// ✅ CORRECT — cancel with invoice_now fires a final invoice before cancelling
Cashier::stripe()->subscriptions->cancel($subscription->stripe_id, [
'invoice_now' => true,
'prorate' => false,
]);
invoice_now: true tells Stripe to finalize and pay all pending metered usage in the same operation. Skip this and any accumulated usage is silently lost. If some of those users also have cards with no funds — and they will — you've already lost the money twice.
On registration, fire the Registered event and grant a few free starter credits so users can try the product before buying:
Event::listen(Registered::class, function (Registered $event) {
app(CreditService::class)->grantStarterCredits($event->user);
});
grantStarterCredits is idempotent — it checks for existing transactions before adding, so running it twice (e.g. in a migration or a seeder) doesn't double-grant.
credit_transactions would be millions of rows. Your domain tables already have that data.syncPaymentMethod on day one. The PM not being saved is a silent failure — auto-recharge simply never triggers, and you won't notice until a user complains their balance ran out.If you have questions or want to share how you handle credits in your own app, I'd love to hear — find me on BlueSky or drop a comment below.
I consider myself as an IT Business Artisan. Or Consultant CTO. I'm a self-taught Web Developper, coach and teacher. My main work is helping and guiding digital startups.
more about meBTC
bc1qgw9a8hyqqwvcls9ln7vhql4mad0qkveutr2td7
ETH
0x3A720717Da03dB6f3c4a4Ff08BC64100205A79d0
2026 © My Dynamic Production SRL All rights Reserved.