1. Introduction: Why Stripe for Laravel SaaS Billing?
Every SaaS application needs billing, and Stripe is the gold standard for payment processing. Combined with Laravel Cashier, you get a first-party integration that handles subscriptions, invoices, payment methods, proration, and more — without writing raw Stripe API calls.
In this complete Laravel Stripe billing guide, we'll build a production-ready subscription system from scratch. You'll learn how to set up Cashier, create a pricing page, handle webhooks, implement free trials, add usage-based billing, and deploy with confidence.
Here's what makes the Laravel + Stripe stack the best choice for SaaS billing in 2026:
- Laravel Cashier: Official package that wraps Stripe's API into fluent, Eloquent-style methods.
- Stripe Checkout: Pre-built, hosted payment pages that handle PCI compliance for you.
- Customer Portal: Let users update their card, switch plans, and download invoices — zero custom UI needed.
- Webhook handling: Cashier automatically verifies signatures and dispatches events.
- Tax automation: Stripe Tax calculates and collects sales tax in 50+ countries.
By the end of this guide, you'll have a fully working billing system. If you want to skip the setup and get all of this pre-built, LaraSpeed includes complete Stripe integration out of the box.
2. Setting Up Laravel Cashier & Stripe
Install Laravel Cashier and publish its migrations:
composer require laravel/cashier
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
This creates the subscriptions, subscription_items, and adds Stripe columns to your users table. Add the Billable trait to your User model:
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
}
Add your Stripe keys to .env:
STRIPE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_SECRET=sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
Finally, register the Cashier webhook route in your routes/web.php. Cashier handles this automatically in Laravel 12 — the route is registered at /stripe/webhook by default. Just make sure to exclude it from CSRF verification:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->validateCsrfTokens(except: [
'stripe/*',
]);
})
3. Creating Products & Prices in Stripe
Head to the Stripe Dashboard and create your products. For a typical SaaS, you'll want at least two tiers:
Product: "Pro Plan"
├── Price: $29/month (price_xxx_monthly)
└── Price: $290/year (price_xxx_yearly)
Product: "Business Plan"
├── Price: $79/month (price_yyy_monthly)
└── Price: $790/year (price_yyy_yearly)
Store your price IDs in a config file so they're easy to reference:
// config/stripe.php
return [
'prices' => [
'pro_monthly' => env('STRIPE_PRICE_PRO_MONTHLY'),
'pro_yearly' => env('STRIPE_PRICE_PRO_YEARLY'),
'business_monthly' => env('STRIPE_PRICE_BUSINESS_MONTHLY'),
'business_yearly' => env('STRIPE_PRICE_BUSINESS_YEARLY'),
],
];
Then add the actual price IDs in your .env:
STRIPE_PRICE_PRO_MONTHLY=price_1Pxxxxxxxxxxxxxx
STRIPE_PRICE_PRO_YEARLY=price_1Pyyyyyyyyyyyyyy
STRIPE_PRICE_BUSINESS_MONTHLY=price_1Pzzzzzzzzzzzzzz
STRIPE_PRICE_BUSINESS_YEARLY=price_1Pwwwwwwwwwwwwww
4. Stripe Checkout: The Fastest Way to Accept Payments
Stripe Checkout is a hosted payment page that handles card collection, validation, 3D Secure, and PCI compliance. It's the fastest path to accepting payments:
// app/Http/Controllers/SubscriptionController.php
use Illuminate\Http\Request;
class SubscriptionController extends Controller
{
public function checkout(Request $request)
{
$priceId = $request->input('price_id');
return $request->user()->newSubscription('default', $priceId)
->checkout([
'success_url' => route('dashboard') . '?checkout=success',
'cancel_url' => route('pricing') . '?checkout=cancelled',
]);
}
}
The route and form are straightforward:
// routes/web.php
Route::post('/subscribe', [SubscriptionController::class, 'checkout'])
->middleware('auth')
->name('subscribe');
// In your Blade template
<form action="{{ route('subscribe') }}" method="POST">
@csrf
<input type="hidden" name="price_id" value="price_1Pxxxxxx">
<button type="submit">Subscribe to Pro</button>
</form>
When the user clicks "Subscribe", they're redirected to Stripe's hosted checkout page. After successful payment, they return to your success URL with an active subscription.
Pro tip: You can also allow promotion codes at checkout:
return $request->user()->newSubscription('default', $priceId)
->allowPromotionCodes()
->checkout([
'success_url' => route('dashboard') . '?checkout=success',
'cancel_url' => route('pricing'),
]);
5. Building a Dynamic Pricing Page
A good pricing page shows available plans, highlights the current plan for logged-in users, and handles the monthly/yearly toggle. Here's a clean approach:
// app/Http/Controllers/PricingController.php
class PricingController extends Controller
{
public function index()
{
$plans = [
[
'name' => 'Pro',
'monthly_price' => 29,
'yearly_price' => 290,
'monthly_id' => config('stripe.prices.pro_monthly'),
'yearly_id' => config('stripe.prices.pro_yearly'),
'features' => [
'Up to 5 team members',
'10,000 API requests/month',
'Priority email support',
'Custom domain',
],
],
[
'name' => 'Business',
'monthly_price' => 79,
'yearly_price' => 790,
'monthly_id' => config('stripe.prices.business_monthly'),
'yearly_id' => config('stripe.prices.business_yearly'),
'features' => [
'Unlimited team members',
'100,000 API requests/month',
'Priority phone & email support',
'Custom domain + SSO',
'Advanced analytics',
],
],
];
return view('pricing', compact('plans'));
}
}
In your Blade template, use Alpine.js for the billing toggle:
<div x-data="{ yearly: false }">
<!-- Toggle -->
<div class="flex items-center justify-center gap-3 mb-8">
<span :class="!yearly ? 'text-white' : 'text-gray-500'">Monthly</span>
<button @click="yearly = !yearly"
class="relative w-14 h-7 bg-gray-700 rounded-full">
<span :class="yearly ? 'translate-x-7' : 'translate-x-1'"
class="block w-5 h-5 mt-1 bg-green-400 rounded-full
transition-transform"></span>
</button>
<span :class="yearly ? 'text-white' : 'text-gray-500'">
Yearly <span class="text-green-400 text-sm">(Save 17%)</span>
</span>
</div>
<!-- Plan Cards -->
@foreach ($plans as $plan)
<div class="border border-white/10 rounded-xl p-8">
<h3 class="text-xl font-bold">{{ $plan['name'] }}</h3>
<p class="mt-4 text-4xl font-extrabold">
$<span x-text="yearly ? '{{ $plan['yearly_price'] }}'
: '{{ $plan['monthly_price'] }}'"></span>
<span class="text-lg text-gray-500"
x-text="yearly ? '/year' : '/month'"></span>
</p>
<form action="{{ route('subscribe') }}" method="POST">
@csrf
<input type="hidden" name="price_id"
:value="yearly ? '{{ $plan['yearly_id'] }}'
: '{{ $plan['monthly_id'] }}'">
<button type="submit">Get Started</button>
</form>
</div>
@endforeach
</div>
6. Free Trials: With and Without Payment Method
Laravel Cashier supports two types of free trials, and choosing the right one significantly impacts your conversion rate.
Trial Without Payment Method (Generic Trial)
Best for maximizing signups. Users get full access immediately without entering a card:
// In your User model
public function onGenericTrial(): bool
{
return $this->trial_ends_at?->isFuture() ?? false;
}
// When creating a user (e.g., in RegisterController)
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'trial_ends_at' => now()->addDays(14),
]);
Then gate features by checking either the trial or subscription:
if ($user->onTrial() || $user->subscribed('default')) {
// Allow access to paid features
}
Trial With Payment Method (Subscription Trial)
Higher conversion to paid, because the card is already on file. The subscription starts automatically when the trial ends:
return $request->user()
->newSubscription('default', $priceId)
->trialDays(14)
->checkout([
'success_url' => route('dashboard'),
'cancel_url' => route('pricing'),
]);
Which should you choose? If you're pre-product-market-fit and want maximum adoption, use generic trials. If you have a proven product and want to maximize revenue, use subscription trials with a payment method.
7. Handling Stripe Webhooks Properly
Webhooks are how Stripe tells your app about events that happen outside your control — failed payments, subscription cancellations, invoice finalization. This is the most critical part of your billing system.
Cashier handles the most important webhooks automatically (subscription creation, updates, deletion, invoice payments). But you'll often need custom logic. Create a listener:
// app/Listeners/StripeEventListener.php
use Laravel\Cashier\Events\WebhookReceived;
class StripeEventListener
{
public function handle(WebhookReceived $event): void
{
match ($event->payload['type']) {
'invoice.payment_failed' => $this->handlePaymentFailed($event),
'customer.subscription.deleted' => $this->handleCancelled($event),
default => null,
};
}
private function handlePaymentFailed(WebhookReceived $event): void
{
$stripeId = $event->payload['data']['object']['customer'];
$user = User::where('stripe_id', $stripeId)->first();
if ($user) {
$user->notify(new PaymentFailedNotification());
Log::warning('Payment failed', [
'user_id' => $user->id,
'stripe_id' => $stripeId,
]);
}
}
private function handleCancelled(WebhookReceived $event): void
{
$stripeId = $event->payload['data']['object']['customer'];
$user = User::where('stripe_id', $stripeId)->first();
if ($user) {
$user->notify(new SubscriptionCancelledNotification());
}
}
}
Register the listener in your AppServiceProvider:
// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Event;
use Laravel\Cashier\Events\WebhookReceived;
use App\Listeners\StripeEventListener;
public function boot(): void
{
Event::listen(WebhookReceived::class, StripeEventListener::class);
}
Important: Set up your webhook endpoint in the Stripe Dashboard. Point it to https://yourdomain.com/stripe/webhook and select these events at minimum:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failedinvoice.finalized
For local development, use the Stripe CLI to forward webhooks:
stripe listen --forward-to localhost:8000/stripe/webhook
8. Customer Portal: Let Users Manage Their Own Billing
Stripe's Customer Portal gives your users a self-service UI to update their payment method, switch plans, view invoices, and cancel — without you building any of it:
// app/Http/Controllers/BillingController.php
class BillingController extends Controller
{
public function portal(Request $request)
{
return $request->user()->redirectToBillingPortal(
route('dashboard')
);
}
}
// routes/web.php
Route::get('/billing', [BillingController::class, 'portal'])
->middleware('auth')
->name('billing');
In your dashboard, add a simple billing link:
<a href="{{ route('billing') }}"
class="text-green-400 hover:text-green-300">
Manage Billing & Invoices
</a>
Configure which features are available in the portal via the Stripe Dashboard under Settings → Customer portal. You can enable/disable plan switching, cancellation, invoice history, and payment method updates.
Pro tip: If you want to show billing info inline without redirecting to Stripe, Cashier provides helper methods:
// Get the current plan name
$user->subscription('default')->stripe_price;
// Check if on a specific plan
$user->subscribedToPrice('price_xxx_monthly', 'default');
// Get upcoming invoice amount
$invoice = $user->upcomingInvoice();
$amount = $invoice->total(); // Returns formatted amount
// Download past invoices
$invoices = $user->invoices();
@foreach ($invoices as $invoice)
<a href="{{ $invoice->asStripeInvoice()->invoice_pdf }}">
{{ $invoice->date()->toFormattedDateString() }}
— {{ $invoice->total() }}
</a>
@endforeach
9. Restricting Features by Plan
One of the most common SaaS requirements is gating features based on which plan a user is on. Here's a clean middleware approach:
// app/Http/Middleware/RequiresPlan.php
class RequiresPlan
{
public function handle(Request $request, Closure $next, string ...$plans)
{
$user = $request->user();
if (!$user->subscribed('default') && !$user->onTrial()) {
return redirect()->route('pricing')
->with('message', 'Please subscribe to access this feature.');
}
if (!empty($plans)) {
$allowedPrices = collect($plans)
->flatMap(fn ($plan) => [
config("stripe.prices.{$plan}_monthly"),
config("stripe.prices.{$plan}_yearly"),
]);
$currentPrice = $user->subscription('default')?->stripe_price;
if (!$allowedPrices->contains($currentPrice)) {
return redirect()->route('pricing')
->with('message', 'Please upgrade your plan.');
}
}
return $next($request);
}
}
Register it and use it on your routes:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'plan' => RequiresPlan::class,
]);
})
// routes/web.php
Route::middleware(['auth', 'plan'])->group(function () {
// Any subscribed user can access
Route::get('/projects', [ProjectController::class, 'index']);
});
Route::middleware(['auth', 'plan:business'])->group(function () {
// Only Business plan users
Route::get('/analytics', [AnalyticsController::class, 'index']);
Route::get('/sso', [SSOController::class, 'index']);
});
You can also check plan access in Blade templates:
@if (auth()->user()->subscribedToPrice(config('stripe.prices.business_monthly'))
|| auth()->user()->subscribedToPrice(config('stripe.prices.business_yearly')))
<a href="/analytics">Advanced Analytics</a>
@else
<a href="/pricing" class="text-gray-500">
Analytics (Upgrade to Business)
</a>
@endif
10. Usage-Based & Metered Billing
If your SaaS charges per API call, per seat, or per resource (like AI tokens or storage), you need metered billing. Create a metered price in Stripe, then report usage:
// Create a metered subscription
$user->newSubscription('default', 'price_metered_xxx')
->meteredPrice('price_metered_xxx')
->checkout([...]);
// Report usage when it happens (e.g., after an API call)
$user->subscription('default')
->reportUsage(quantity: 1);
For AI-powered SaaS apps that charge per token, you can report usage after each AI request:
// After processing an AI request
$tokensUsed = $response->usage()->totalTokens;
$user->subscription('default')
->reportUsage(quantity: $tokensUsed);
// Or batch-report usage with a scheduled command
// app/Console/Commands/ReportDailyUsage.php
class ReportDailyUsage extends Command
{
protected $signature = 'billing:report-usage';
public function handle(): void
{
User::whereHas('subscriptions')->each(function ($user) {
$usage = $user->apiRequests()
->where('created_at', '>=', now()->startOfDay())
->sum('tokens_used');
if ($usage > 0) {
$user->subscription('default')
->reportUsage(quantity: $usage);
}
});
$this->info('Usage reported for all active subscribers.');
}
}
Hybrid model: Many SaaS products combine a flat monthly fee with usage overages. You can do this with Cashier by creating a subscription with both a flat price and a metered price:
$user->newSubscription('default', ['price_flat_monthly', 'price_metered_api'])
->meteredPrice('price_metered_api')
->checkout([...]);
11. Team Billing: Per-Seat Pricing
For B2B SaaS products, you typically bill the team or organization rather than individual users. Move the Billable trait to your Team model:
use Laravel\Cashier\Billable;
class Team extends Model
{
use Billable;
}
Update the Cashier configuration to use the Team model:
// config/cashier.php
'model' => App\Models\Team::class,
For per-seat pricing, update the subscription quantity when team members change:
// When a member is added
class AddTeamMember
{
public function handle(Team $team, User $user): void
{
$team->members()->attach($user->id, ['role' => 'member']);
if ($team->subscribed('default')) {
$team->subscription('default')
->updateQuantity($team->members()->count());
}
}
}
// When a member is removed
class RemoveTeamMember
{
public function handle(Team $team, User $user): void
{
$team->members()->detach($user->id);
if ($team->subscribed('default')) {
$team->subscription('default')
->updateQuantity($team->members()->count());
}
}
}
Stripe automatically prorates the charge when the quantity changes mid-cycle.
12. Testing Billing with Stripe Test Mode
Stripe provides test card numbers and a complete test environment. Here's how to write reliable billing tests with Pest:
// tests/Feature/BillingTest.php
use App\Models\User;
it('can check if user is subscribed', function () {
$user = User::factory()->create([
'trial_ends_at' => now()->addDays(14),
]);
expect($user->onTrial())->toBeTrue();
expect($user->onGenericTrial())->toBeTrue();
});
it('restricts features for non-subscribers', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/projects')
->assertRedirect('/pricing');
});
it('allows access for subscribed users', function () {
$user = User::factory()->create([
'trial_ends_at' => now()->addDays(14),
]);
$this->actingAs($user)
->get('/projects')
->assertOk();
});
For integration tests that actually hit the Stripe API (use sparingly), use these test card numbers:
4242 4242 4242 4242— Successful payment4000 0000 0000 0002— Card declined4000 0025 0000 3155— Requires 3D Secure authentication4000 0000 0000 9995— Insufficient funds
Pro tip: Use Stripe's test clocks to simulate time-based scenarios like trial expirations, subscription renewals, and payment retries without waiting.
13. Going Live: Production Checklist
Before switching from test mode to live mode, run through this checklist:
- Switch to live Stripe keys — Replace
pk_test_andsk_test_with your live keys in production.env. - Set up the live webhook endpoint — Create a new webhook in the Stripe Dashboard pointing to your production URL. Update
STRIPE_WEBHOOK_SECRET. - Enable Stripe Tax — If you sell to customers in multiple countries, configure Stripe Tax for automatic tax calculation.
- Configure the Customer Portal — Enable plan switching, cancellation, and invoice downloads in the Stripe Dashboard.
- Set up dunning emails — Configure Stripe's automatic retry schedule and failed payment emails under Settings → Subscriptions and emails.
- Test the full flow manually — Subscribe, upgrade, downgrade, cancel, and re-subscribe using a live test card before launching.
- Monitor webhook delivery — Set up Stripe's webhook event logs and alerts so you know immediately if webhooks fail.
- Add billing error logging — Log all Stripe exceptions and failed charges so you can debug payment issues quickly.
14. Conclusion
You now have a complete Laravel Stripe billing system with subscriptions, free trials, webhook handling, a customer portal, plan-based feature gating, usage-based billing, and team billing with per-seat pricing.
This is a lot of code to set up and maintain. Every edge case — failed payments, proration, plan changes, trial expirations, webhook retries — needs to be handled correctly, or you'll lose revenue or frustrate customers.
LaraSpeed includes all of this out of the box, pre-built and tested. You get complete Stripe integration with a dynamic pricing page, free trials, webhooks, team billing, plan-based middleware, and the Customer Portal — plus authentication, multi-tenancy, admin panel, and more.
Skip weeks of billing setup
LaraSpeed gives you a production-ready Laravel SaaS with Stripe billing, authentication, teams, admin panel, and AI features — all wired together and ready to customize.
Get LaraSpeed — Starting at $49