Building subscription billing from scratch is complex. Laravel Cashier handles Stripe integration elegantly—subscriptions, trials, invoices, webhooks, and more. At ZIRA Software, Cashier powers SaaS billing for clients generating millions in recurring revenue.
Installation
composer require laravel/cashier
php artisan migrate
Add trait to User model:
<?php
use Laravel\Cashier\Billable;
class User extends Authenticatable
{
use Billable;
}
Publish configuration:
php artisan vendor:publish --tag="cashier-config"
Stripe Configuration
.env:
STRIPE_KEY=pk_test_...
STRIPE_SECRET=sk_test_...
CASHIER_CURRENCY=usd
Create plans in Stripe Dashboard:
- Basic: $10/month
- Pro: $20/month
- Enterprise: $50/month
Creating Subscriptions
Basic subscription:
$user->newSubscription('default', 'price_basic')->create($paymentMethod);
With trial:
$user->newSubscription('default', 'price_pro')
->trialDays(14)
->create($paymentMethod);
With coupon:
$user->newSubscription('default', 'price_pro')
->withCoupon('SAVE20')
->create($paymentMethod);
Checking Subscription Status
if ($user->subscribed('default')) {
// User has active subscription
}
if ($user->subscribedToPlan('price_pro', 'default')) {
// User subscribed to specific plan
}
if ($user->subscribed('default', 'price_pro')) {
// Alternative syntax
}
if ($user->subscription('default')->onGracePeriod()) {
// Canceled but still active
}
if ($user->subscription('default')->onTrial()) {
// On trial period
}
Updating Payment Method
Save payment method:
$user->updateDefaultPaymentMethod($paymentMethod);
Get default payment method:
$paymentMethod = $user->defaultPaymentMethod();
echo $paymentMethod->card->brand; // visa
echo $paymentMethod->card->last4; // 4242
Controller example:
public function updatePaymentMethod(Request $request)
{
$user = $request->user();
$user->updateDefaultPaymentMethod($request->paymentMethod);
return back()->with('success', 'Payment method updated!');
}
Changing Plans
Swap immediately:
$user->subscription('default')->swap('price_pro');
Swap at period end:
$user->subscription('default')->swapAndInvoice('price_pro');
Prorate billing:
$user->subscription('default')
->prorate()
->swap('price_pro');
Canceling Subscriptions
Cancel at period end:
$user->subscription('default')->cancel();
Cancel immediately:
$user->subscription('default')->cancelNow();
Resume canceled subscription:
$user->subscription('default')->resume();
Subscription Middleware
Protect routes:
Route::middleware(['auth', 'subscribed'])->group(function () {
Route::get('/dashboard', 'DashboardController@index');
Route::get('/reports', 'ReportController@index');
});
Custom middleware:
<?php
namespace App\Http\Middleware;
use Closure;
class CheckSubscription
{
public function handle($request, Closure $next, $plan)
{
if ($request->user() && ! $request->user()->subscribedToPlan($plan, 'default')) {
return redirect('billing');
}
return $next($request);
}
}
Use:
Route::middleware(['auth', 'subscription:price_pro'])->get('/pro-features', function () {
// Only for Pro subscribers
});
Invoices
Get invoices:
$invoices = $user->invoices();
foreach ($invoices as $invoice) {
echo $invoice->date()->toFormattedDateString();
echo $invoice->total();
echo $invoice->download($user->name . '_invoice.pdf');
}
Invoice table:
<table>
<thead>
<tr>
<th>Date</th>
<th>Total</th>
<th>Download</th>
</tr>
</thead>
<tbody>
@foreach ($invoices as $invoice)
<tr>
<td>{{ $invoice->date()->toFormattedDateString() }}</td>
<td>${{ $invoice->total() }}</td>
<td>
<a href="{{ route('invoice.download', $invoice->id) }}">
Download
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
One-Time Charges
$user->charge(1000, $paymentMethod); // $10.00
$user->charge(1000, $paymentMethod, [
'description' => 'Custom domain setup'
]);
Webhooks
Register webhook URL in Stripe:
https://yourapp.com/stripe/webhook
Events Cashier handles:
customer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed
Custom webhook handler:
<?php
namespace App\Listeners;
use Laravel\Cashier\Events\WebhookReceived;
class StripeEventListener
{
public function handle(WebhookReceived $event)
{
if ($event->payload['type'] === 'invoice.payment_succeeded') {
// Send receipt email
}
}
}
Register listener:
protected $listen = [
'Laravel\Cashier\Events\WebhookReceived' => [
'App\Listeners\StripeEventListener',
],
];
Metered Billing
Report usage:
$user->subscription('default')->recordUsage(50);
Subscription UI
Billing portal:
<a href="{{ route('billing') }}">Manage Billing</a>
Controller:
public function billing()
{
return view('billing', [
'user' => auth()->user(),
'intent' => auth()->user()->createSetupIntent()
]);
}
View:
<form id="payment-form" action="{{ route('billing.update') }}" method="POST">
@csrf
<div id="card-element"></div>
<button id="card-button" data-secret="{{ $intent->client_secret }}">
Update Payment Method
</button>
</form>
<script src="https://js.stripe.com/v3/"></script>
<script>
const stripe = Stripe('{{ config('cashier.key') }}');
const elements = stripe.elements();
const cardElement = elements.create('card');
cardElement.mount('#card-element');
const cardButton = document.getElementById('card-button');
const clientSecret = cardButton.dataset.secret;
cardButton.addEventListener('click', async (e) => {
e.preventDefault();
const { setupIntent, error } = await stripe.confirmCardSetup(
clientSecret, {
payment_method: {
card: cardElement,
}
}
);
if (error) {
console.error(error);
} else {
document.getElementById('payment-form').submit();
}
});
</script>
Testing
Mock Cashier:
use Laravel\Cashier\Cashier;
public function setUp(): void
{
parent::setUp();
Cashier::useCustomerModel(TestCustomer::class);
}
Test subscriptions:
/** @test */
public function user_can_subscribe()
{
$user = factory(User::class)->create();
$user->newSubscription('default', 'price_basic')->create('pm_card_visa');
$this->assertTrue($user->subscribed('default'));
}
Best Practices
- Handle webhooks - Critical for subscription updates
- Test with test mode - Use Stripe test cards
- Implement trials - Convert more users
- Show invoices - Transparency builds trust
- Handle failures - Retry failed payments
- Update payment methods - Expired cards happen
- Monitor metrics - MRR, churn, LTV
Common Patterns
Freemium model:
if ($user->onGenericTrial()) {
// Free trial without payment method
}
// After trial, require subscription
if (!$user->subscribed() && !$user->onGenericTrial()) {
return redirect('subscribe');
}
Feature gates:
if ($user->subscribedToPlan('price_pro')) {
// Pro features
} elseif ($user->subscribedToPlan('price_basic')) {
// Basic features
} else {
// Free features
}
Conclusion
Laravel Cashier eliminates subscription billing complexity. From plans to invoices to webhooks, Cashier handles Stripe integration elegantly. At ZIRA Software, Cashier powers reliable recurring revenue for our SaaS clients.
Start with basic subscriptions. Add trials to convert users. Monitor metrics to optimize pricing.
Building a SaaS application with subscription billing? Contact ZIRA Software to discuss payment integration, pricing strategies, and subscription architecture.