Adding a Stripe Customer Portal to your Laravel app with Cashier
Building a billing management UI from scratch — plan upgrades, cancellations, invoice downloads, payment method updates — is weeks of work you don't need to do. Stripe's Customer Portal handles all of it, and Cashier gives you a one-liner to redirect users straight there.
What the Stripe Customer Portal gives you for free
Before touching any code, enable the portal in your Stripe Dashboard under Settings → Billing → Customer portal. You toggle exactly which features are available:
- Update payment methods (cards, wallets, SEPA, etc.)
- Cancel subscriptions immediately or at period end
- Upgrade or downgrade plans
- Download and pay invoices
- Update billing address and tax IDs
- Retention coupons during cancellation
The UI is Stripe-hosted, branded to match your settings, and localised across 40+ languages. You configure it once in the dashboard — not in code.
Setting up the Stripe Customer Portal with Cashier
Your User model (or whichever model uses the Billable trait) gets two portal methods automatically:
// Returns the portal URL as a string — useful if you need to pass it to a view
$url = $user->billingPortalUrl(route('dashboard'));
// Returns a RedirectResponse directly
return $user->redirectToBillingPortal(route('dashboard'));
The $returnUrl parameter tells Stripe where to send the user after they leave the portal. If you omit it, Stripe uses whatever you've set as the default return URL in the dashboard.
A minimal controller looks like this:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class BillingController extends Controller
{
public function portal(Request $request): RedirectResponse
{
// redirectToBillingPortal() calls the Stripe API and returns a redirect
return $request->user()->redirectToBillingPortal(
route('dashboard')
);
}
}
And the route:
// routes/web.php
Route::get('/billing/portal', [BillingController::class, 'portal'])
->middleware(['auth', 'verified'])
->name('billing.portal');
That's the entire implementation. Drop a link to route('billing.portal') in your account settings page and your users have a full billing management UI.
Handling subscription changes via webhook
When a user cancels, upgrades, or downgrades through the portal, Stripe fires webhooks back to your app. Cashier handles the core events automatically — but only if your webhooks are configured.
Verify your webhook endpoint is registered in the Stripe Dashboard pointing to /stripe/webhook, and that you've added the webhook secret to your .env:
STRIPE_WEBHOOK_SECRET=whsec_your_secret_here
Cashier handles customer.subscription.deleted and customer.subscription.updated out of the box. For anything custom — say, downgrading a user to a free plan on cancellation — listen to Cashier's events:
// app/Providers/AppServiceProvider.php
use Laravel\Cashier\Events\WebhookReceived;
use App\Listeners\HandleStripeWebhook;
public function boot(): void
{
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
}
// app/Listeners/HandleStripeWebhook.php
class HandleStripeWebhook
{
public function handle(WebhookReceived $event): void
{
if ($event->payload['type'] === 'customer.subscription.deleted') {
$stripeId = $event->payload['data']['object']['customer'];
// Find the user by their Stripe customer ID
$user = User::where('stripe_id', $stripeId)->first();
$user?->update(['plan' => 'free']);
}
}
}
Test the full flow locally with the Stripe CLI:
stripe listen --forward-to http://localhost/stripe/webhook
Gotchas and Edge Cases
Portal URLs expire. Stripe generates a short-lived session URL each time billingPortalUrl() or redirectToBillingPortal() is called. Never cache the URL — always generate fresh on each request.
The user must be a Stripe customer. If the user has never checked out or had a subscription created, they won't have a stripe_id. Calling the portal methods on a non-customer throws an exception. Guard against this:
public function portal(Request $request): RedirectResponse
{
$user = $request->user();
// Create a Stripe customer record if one doesn't exist yet
$user->createOrGetStripeCustomer();
return $user->redirectToBillingPortal(route('dashboard'));
}
Portal configuration is per-account, not per-session. You can't pass custom portal settings (e.g., hide cancellation for certain users) through Cashier's helpers. If you need per-user portal configurations, you'll need to call the Stripe API directly to create a portal session with a specific configuration ID.
Webhooks are not optional. Without them, your database won't reflect what users do in the portal. Subscription status will drift. Set them up before you ship.
Wrapping Up
Add redirectToBillingPortal() to a controller, protect the route, and link to it — that's the entire stripe customer portal laravel cashier integration. Configure the portal features in the Stripe Dashboard, set up your webhook endpoint, and test cancellation and plan changes with the Stripe CLI before going live.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.