Recurring donations with Stripe subscriptions
Start a monthly gift as a Stripe Checkout subscription with a custom amount, and record each renewal from the invoice-paid webhook branch.
LaravelPaymentsstripesubscriptionrecurringdonations
Code
<?php
namespace App\Fundraising;
use App\Models\Campaign;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
class MonthlyGiving
{
/** Returns the hosted Checkout URL for a monthly gift of any amount. */
public function start(Campaign $campaign, int $cents, string $currency): string
{
$session = Http::asForm()->withToken(config('services.stripe.secret'))
->baseUrl('https://api.stripe.com/v1')
->post('/checkout/sessions', [
'mode' => 'subscription',
'success_url' => route('campaigns.thanks', $campaign),
'cancel_url' => route('campaigns.show', $campaign),
'line_items[0][quantity]' => 1,
'line_items[0][price_data][currency]' => strtolower($currency),
'line_items[0][price_data][unit_amount]' => $cents,
'line_items[0][price_data][recurring][interval]' => 'month',
'line_items[0][price_data][product_data][name]' => "Monthly gift to {$campaign->title}",
// Copied onto the subscription and every invoice it produces.
'subscription_data[metadata][campaign_id]' => $campaign->id,
])->throw()->json();
return $session['url'];
}
/** Webhook branch for `invoice.paid`: first payment and every renewal. */
public function onInvoicePaid(array $invoice): void
{
$campaignId = $invoice['subscription_details']['metadata']['campaign_id'] ?? null;
if (! $campaignId || ($invoice['amount_paid'] ?? 0) <= 0) {
return;
}
// Unique invoice id: a redelivered event inserts nothing.
DB::table('donations')->insertOrIgnore([
'campaign_id' => $campaignId,
'amount_cents' => $invoice['amount_paid'],
'currency' => $invoice['currency'],
'provider_invoice_id' => $invoice['id'],
'recurring' => true,
'created_at' => now(),
]);
}
}
How it works
What it solves
Donors choose their own monthly amount, so a fixed catalogue of Stripe prices does not fit. You still want Stripe to handle the card, the schedule and the retries. This class starts a subscription with an inline price for any amount and records each charge when Stripe reports it.
How it works
start() creates a Checkout session in subscription mode. The price is described inline with price_data, including a recurring[interval] of month, so no price object needs to exist beforehand. The campaign and donor are copied into subscription_data[metadata], which Stripe attaches to the subscription and to every invoice it later produces.
onInvoicePaid() is the one webhook branch you need for renewals. Stripe sends invoice.paid for the first payment and every renewal. The method reads the metadata from the subscription details, then inserts a donation row keyed by the invoice id. A unique index on that id makes a redelivered event harmless via insertOrIgnore.
Call onInvoicePaid from your webhook controller, after the signature has been verified, when the event type matches.
Adapting it
Add a yearly interval option, link the donation to the monthly gift row to show a supporter history, and cancel with the subscription id through the Stripe API from the donor's account page.
Pitfalls
- Minimum amounts differ per currency; validate before calling Stripe.
- Handle
invoice.payment_failedseparately so donors are asked to update their card.
How to use it
- Add a unique
provider_invoice_idcolumn to yourdonationstable. - Put
services.stripe.secretin config and copy the class. - Start a gift:
redirect()->away(app(MonthlyGiving::class)->start($campaign, 2500, 'usd')). - In your verified webhook controller:
if ($event['type'] === 'invoice.paid') { app(MonthlyGiving::class)->onInvoicePaid($event['data']['object']); }. - Use test mode and the Stripe CLI to replay
invoice.paid.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.