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. All portal-triggered events follow the same webhook signature format; if you're also handling webhooks outside Cashier, verifying Stripe webhook signatures in Laravel covers the raw constructEvent() verification pattern:
// 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.
If you're building out a full billing system, implementing Stripe trials for memberships covers the subscription creation and trial period patterns that precede portal access, and usage-based billing with Stripe Meters covers the metered pricing model as an alternative to flat-rate subscriptions. For high-volume webhook processing, scaling Laravel queues in production ensures webhook jobs are processed reliably under load.
FAQ#
Can I customise the portal look and feel per user or per plan?
No, portal configuration is account-wide and set once in the Stripe Dashboard. All users see the same branding, layout, and feature toggles. For per-user customisation (e.g. hiding cancellation for VIP customers), you'd need to build a custom billing UI instead of using the portal.
What if a user doesn't have a Stripe customer record yet?
The portal methods throw an exception. Always call $user->createOrGetStripeCustomer() before redirecting. This creates a lightweight customer record if missing, without charging anything. It's safe to call repeatedly.
Do I need to notify users when they change their plan in the portal?
Stripe fires webhook events (customer.subscription.updated, customer.subscription.deleted) when portal actions happen. Listen for those events and send emails or trigger other business logic from your webhook handler. Don't rely on the portal itself to notify — you own that experience.
Can customers pay an unpaid invoice through the portal?
Yes, the portal includes an invoice list with payment options. Customers can download, view, and pay outstanding invoices. You don't need to build that UI.