Laravel Spark is an official, first-party billing management package that provides a complete customer portal for Laravel applications using Stripe or Paddle. It abstracts subscription lifecycle operations, recurring invoicing, receipt generation, payment method collection, and tier swaps into an isolated billing hub mounted directly onto your existing application routes.
Building custom subscription billing from scratch is an anti-pattern for modern software engineering teams. Rolling bespoke checkout flows, proration logic, currency conversions, and automated webhook reconciliation does not yield competitive differentiation. Instead, it introduces high-risk compliance liabilities and edge-case operational failures into your primary application state.
While earlier iterations of Spark attempted to serve as an opinionated, monolithic SaaS starter kit that dictated frontend scaffolding and user management, the modern package acts strictly as a dedicated billing sub-layer. Understanding how Spark isolates billing state, synchronizes webhooks asynchronously, and interfaces with underlying Cashier drivers is critical for building maintainable, fault-tolerant SaaS backends.
Architectural Overview of Spark and Cashier Foundations
At its architectural foundation, Laravel Spark acts as a high-level UI and orchestration wrapper built on top of Laravel Cashier. Cashier handles raw API communication with payment gateways, model attribute mapping, and webhook payload translation. Spark sits above Cashier to deliver an isolated, pre-rendered billing portal powered by Inertia.js, Tailwind CSS, and Alpine.js, mounted directly inside your host application runtime.
Developers frequently conflate Spark with general application scaffolding packages such as Jetstream or Breeze. Spark does not handle authentication, team authorization rules, multi-factor challenges, or profile settings. Instead, it delegates authentication entirely to the host application guard and intercepts requests strictly on configured billing routes, typically /billing.
The Billable Entity Contract
Spark operates against a billable model that uses the Laravel\Spark\Billable trait. Depending on your SaaS structure, the billable model can be an individual App\Models\User entity or an organization entity such as App\Models\Team. This trait extends Cashier capabilities by exposing methods to query subscription states, retrieve invoices, evaluate proration, and generate secure gateway tokens.
<php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Spark\Billable;
class User extends Authenticatable
{
use HasFactory, Notifiable, Billable;
protected $fillable = [
'name',
'email',
'password',
'trial_ends_at',
];
protected $casts = [
'email_verified_at' => 'datetime',
'trial_ends_at' => 'datetime',
];
}
By implementing this trait, the Eloquent model inherits database relationships linking the entity to Cashier records stored in your primary SQL database, including subscriptions and subscription_items tables. Spark queries these models directly when resolving portal views, ensuring absolute operational isolation from third-party gateway latency.
Driver Selection: Stripe versus Paddle Trade-Offs
When integrating Laravel Spark, developers must explicitly install either the Stripe edition (laravel/spark-stripe) or the Paddle edition (laravel/spark-paddle). These editions cannot run concurrently within the same application instance. Selecting the correct driver impacts your database schema, tax liability models, and webhook failure mitigation strategies.
Stripe operates as a payment processor and merchant acquirer. Your organization remains the merchant of record (MoR), which requires you to manage global sales tax, VAT calculation, and jurisdictional filings. In contrast, Paddle operates as a full Merchant of Record, handling remittance, compliance, and tax calculation on your behalf while abstracting transaction execution behind their unified checkout layer.
| Evaluation Metric | Spark Stripe Driver | Spark Paddle Driver |
|---|---|---|
| Merchant of Record | Application Owner (You) | Paddle |
| Tax Compliance Engine | Stripe Tax Integration Required | Handled automatically by Paddle |
| Custom Payment Methods | SEPA, ACH, Cards, Wallets | Credit Card, PayPal, Apple Pay |
| Customer Portal Flow | Hosted locally via Spark routes | Hosted locally with Paddle Overlay Checkout |
| Proration Calculation | Engineered server-side in Cashier | Engineered natively via Paddle APIs |
Choosing between these drivers depends heavily on your team financial ops capacity. Teams operating across multiple international jurisdictions without dedicated tax compliance infrastructure often default to Paddle to minimize accounting overhead, while teams requiring granular control over payment methods and raw customer data rely on Stripe.
Configuration Mechanics and Plan Definition DSL
Spark manages billing tiers, add-ons, pricing options, and seat management using a declarative configuration file located at config/spark.php. Rather than storing plan definitions inside a database table that requires migration syncing across environments, Spark defines products through a expressive PHP Domain-Specific Language (DSL).
This design enforces that billing structure is version-controlled inside your repository, eliminating out-of-sync plan identifiers between development, staging, and production environments. Dynamic runtime values, such as raw Stripe price IDs, are injected using environment variables.
<php
use Laravel\Spark\Spark;
return [
'path' => 'billing',
'middleware' => ['web', 'auth'],
'brand' => [
'logo' => realpath(__DIR__.'/./public/img/brand-logo.svg'),
'color' => 'bg-slate-900',
],
'billables' => [
'user' => [
'model' => App\Models\User:class,
'trial_days' => 14,
'default_interval' => 'monthly',
'plans' => [
[
'name' => 'Standard Developer',
'short_description' => 'Ideal for solo developers building APIs.',
'monthly_id' => env('STRIPE_STANDARD_MONTHLY_PLAN'),
'yearly_id' => env('STRIPE_STANDARD_YEARLY_PLAN'),
'features' => [
'Up to 50,000 requests / month',
'Basic telemetry logging',
'Community support channel',
],
'archived' => false,
],
[
'name' => 'Production Cluster',
'short_description' => 'Dedicated capacity with team collaboration.',
'monthly_id' => env('STRIPE_PROD_MONTHLY_PLAN'),
'yearly_id' => env('STRIPE_PROD_YEARLY_PLAN'),
'features' => [
'Unlimited monthly requests',
'Granular audit traces',
'Zero-downtime failover fail-safes',
],
'archived' => false,
],
],
],
],
];
Within the plan definition array, the archived parameter is especially valuable for long-term maintenance. Setting an archived flag to true prevents new customers from selecting a legacy tier while preserving active subscriptions for existing accounts without disrupting their recurring billing cycles.
Webhook Processing Pipeline and Event Reliability
A billing integration is only as reliable as its webhook processing pipeline. Payment providers notify your application asynchronously of lifecycle updates, such as successful payments, chargebacks, disputed transactions, and card expirations. Spark routes these incoming webhooks through Cashier controllers, verifying cryptographic signatures before emitting events.
Processing webhooks synchronously inside the HTTP request loop introduces severe latency bottlenecks and increases the risk of dropped events during downstream database lock contention. To resolve this, Cashier dispatches internal Laravel events that can be handled asynchronously using background job queues.
<php
namespace App\Listeners;
use Laravel\Cashier\Events\WebhookReceived;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class ProcessStripeWebhookEvent implements ShouldQueue
{
public $queue = 'webhooks';
public $tries = 5;
public $backoff = [10, 30, 120];
public function handle(WebhookReceived $event): void
{
$payload = $event->payload;
$eventType = $payload['type']? 'unknown';
if ($eventType === 'invoice.payment_failed') {
$customerId = $payload['data']['object']['customer'];
// Execute non-blocking downstream alerting logic
Log:warning("Payment failed for customer identifier: {$customerId}");
}
}
}
When scaling high-throughput applications, you can couple these events with real-time notifications to inform users directly in their browser UI. To see how WebSocket architectures handle this without polling, read our deep dive on scalable real-time broadcasting architectures.
Database Schema Modifications and State Management
Installing Spark requires executing the underlying Cashier migrations. These migrations introduce several tables and augment your billable entity tables with specific tracking columns. Understanding these database structures is critical to executing performant SQL queries during middleware entitlement checks.
The migrations append stripe_id, pm_type, pm_last_four, and trial_ends_at to your billable model (such as the users or teams table). In parallel, Cashier provisions two primary tables: subscriptions and subscription_items.
CREATE TABLE `subscriptions` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint unsigned NOT NULL,
`type` varchar(255) NOT NULL DEFAULT 'default',
`stripe_id` varchar(255) NOT NULL,
`stripe_status` varchar(255) NOT NULL,
`stripe_price` varchar(255) DEFAULT NULL,
`quantity` int DEFAULT NULL,
`trial_ends_at` timestamp NULL DEFAULT NULL,
`ends_at` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `subscriptions_stripe_id_unique` (`stripe_id`),
KEY `subscriptions_user_id_stripe_status_index` (`user_id`,`stripe_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Notice the composite index on user_id and stripe_status. This ensures that checks like $user->subscribed('default') execute in sub-millisecond time by hitting index memory rather than performing an unindexed table scan across high-volume subscription tables.
Authorization Gates, Middleware, and Entitlement Checks
Protecting application features based on billing status requires clean separation between authentication and entitlement. Spark provides built-in Cashier verification methods that can be wrapped in custom route middleware or Laravel Gates. Avoid embedding direct Stripe API calls inside request cycles; always evaluate cached database states.
The primary helper methods exposed by the billable model evaluate subscription active state, grace periods, and specific price tiers. When a customer cancels their subscription, Stripe marks the plan to end at the billing period close. During this window, $user->subscribed() returns true, while $user->subscription()->onGracePeriod() also returns true.
<php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureBillingTierSubscribed
{
public function handle(Request $request, Closure $next, string $plan = null): Response
{
$user = $request->user();
if (! $user) {
return redirect()->route('login');
}
// Bypass check if user is on an explicit trial period
if ($user->onTrial()) {
return $next($request);
}
// Validate broad subscription status
if (! $user->subscribed('default')) {
return redirect()->route('spark.portal');
}
// Validate specific tiered entitlements when requested by route arguments
if ($plan && $user->subscribedToPrice(config("spark.plans.{$plan}.monthly_id"))
&& $user->subscribedToPrice(config("spark.plans.{$plan}.yearly_id"))) {
abort(403, 'Tier elevation required to access this resource.');
}
return $next($request);
}
}
Using explicit route middleware keeps route declarations readable and cleanly testable across your unit and feature test suites.
Per-Seat Pricing and Dynamic Subscription Quantities
Collaborative B2B SaaS platforms often bill customers per active team seat rather than charging a flat monthly rate. Spark supports dynamic seat quantity modifications, routing quantity adjustments through Cashier to update billing increments on Stripe or Paddle without forcing plan cancellations.
When implementing seat pricing, your application must synchronize seat adjustments with team membership changes. If an administrator adds a new member, the application fires an event listener that increments subscription quantity via the billable entity interface.
<php
namespace App\Services;
use App\Models\Team;
use App\Models\User;
class TeamSeatManager
{
public function addMember(Team $team, User $user, string $role = 'member'): void
{
$team->users()->attach($user->id, ['role' => $role]);
// Adjust Stripe quantity if the team holds an active paid subscription
if ($team->subscribed('default')) {
$currentSeats = $team->users()->count();
// Update Stripe quantity with automated proration
$team->subscription('default')->updateQuantity($currentSeats);
}
}
public function removeMember(Team $team, int $userId): void
{
$team->users()->detach($userId);
if ($team->subscribed('default')) {
$currentSeats = max(1, $team->users()->count());
$team->subscription('default')->updateQuantity($currentSeats);
}
}
}
When updating subscription quantities programmatically, monitor API rate limits on upstream providers. For distributed architectures handling high-volume synchronization workflows, reviewing API rate limits and integration strategies offers valuable guidelines for handling upstream rate ceilings gracefully.
Customizing the Spark Portal Interface and Routing
While modern Spark intentionally isolates its view layer to ensure seamless upgrades, enterprise applications frequently require customized branding, navigational redirects, or localization overrides. Spark handles UI customizability through configuration options and selective Blade asset publishing.
To alter the entry point URL or impose domain restrictions, configure the path and domain settings in config/spark.php. If you are serving the billing portal exclusively over a designated customer portal subdomain, assign that value directly to the domain key.
'path' => 'portal/billing',
'domain' => env('BILLING_SUBDOMAIN', null),
Customizing Portal Assets and Localization
Spark ships with translatable language strings that can be published to your project resources directory using Artisan CLI commands:
php artisan vendor:publish --tag=spark-lang
This generates resources/lang/vendor/spark directory structures containing translations for plan change confirmations, cancellation notices, payment errors, and receipt download requests. Customizing these text records guarantees brand consistency across all customer communications.
Proration Logic, Plan Swaps, and Edge Cases
Plan changes in subscription systems introduce computational edge cases, specifically around proration. If a user transitions from a $20 monthly plan to a $100 monthly plan halfway through their billing cycle, the system must balance remaining credit from the unspent days against the higher cost of the upgraded tier.
By default, Spark applies immediate proration. Cashier instructs Stripe to calculate the unused balance down to the second, issuing a credit and billing the net difference immediately. However, you can configure plan swaps to defer financial adjustments until the next invoice cycle using explicit method chaining.
// Immediate swap with explicit proration invoice generation
$user->subscription('default')
->swap(config('spark.plans.pro.monthly_id'));
// Swap plan without prorating remaining time credit
$user->subscription('default')
->noProrate()
->swap(config('spark.plans.pro.monthly_id'));
// Swap plan at the end of the current billing cycle
$user->subscription('default')
->swapAndInvoice(config('spark.plans.pro.monthly_id'));
Managing proration explicitly avoids unexpected customer balance charges and minimizes billing inquiries submitted to customer support teams.
Dunning Management and Grace Period Architecture
In subscription commerce, involuntary churn caused by expired cards, temporary bank holds, and insufficient balances accounts for significant revenue loss. Dunning management is the process of retrying failed transactions and warning customers before terminating account access.
Spark delegates dunning sequences directly to your payment gateway configuration. Stripe Smart Retries use machine learning to retry card charges at optimal times over a configured retry window. During this phase, the subscription enters an incomplete or past_due state.
| Subscription State | Database Representation | Application Access Level |
|---|---|---|
active |
stripe_status = 'active' |
Full access to paid features |
trialing |
stripe_status = 'trialing' |
Full access without payment card required |
past_due |
stripe_status = 'past_due' |
Read access; alert displayed to update billing method |
canceled |
stripe_status = 'canceled' |
Access retained if within original billing window |
incomplete_expired |
stripe_status = 'incomplete_expired' |
Locked out; redirected immediately to portal |
To avoid jarring customer disruptions, modern SaaS platforms provide a grace period during past_due states, displaying an unobtrusive reminder banner rather than immediately invalidating active application sessions.
Automated Testing Strategies for Spark Integrations
End-to-end testing of payment flows across live sandbox gateways slows down CI pipelines and introduces flaky network dependencies. A resilient test suite verifies internal entitlement gates and database state mutations using mocked events and factory states.
You can verify access restrictions by setting up local models with pre-configured Cashier subscription records, bypassing remote Stripe network requests entirely.
<php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
class SubscriptionEntitlementTest extends TestCase
{
use RefreshDatabase;
public function test_unsubscribed_users_are_redirected_to_portal(): void
{
$user = User:factory()->create();
$response = $this->actingAs($user)->get('/dashboard/analytics');
$response->assertRedirect(route('spark.portal'));
}
public function test_subscribed_user_can_access_protected_route(): void
{
$user = User:factory()->create();
// Seed mock active subscription into local SQLite / MySQL memory
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_mock_12345',
'stripe_status' => 'active',
'stripe_price' => 'price_prod_cluster_monthly',
'quantity' => 1,
'trial_ends_at' => null,
'ends_at' => null,
]);
$response = $this->actingAs($user)->get('/dashboard/analytics');
$response->assertOk();
}
}
Testing in-memory database states ensures rapid CI suite execution while validating that authorization gates perform accurately under varied account configurations.
Directory Navigation and Topic Resources
Mastering Laravel ecosystem architecture involves connecting payment orchestration, event broadcasting, and high-performance queue processing into a unified system runtime.
[Explore our complete Laravel, Basics directory for more guides.](/topics/topics-laravel-basics/)
Frequently Asked Questions
What is the difference between Laravel Spark and Laravel Cashier?
Laravel Cashier is a free, low-level SDK that provides database models and direct API wrappers for Stripe and Paddle. Laravel Spark is a commercial, first-party package built on top of Cashier that provides a pre-built customer billing portal, complete with invoicing, plan switching, and card management UI.
Can Laravel Spark be used with teams and organizations?
Yes. Spark natively supports team billing. By applying the Billable trait to your Team model instead of the User model, billing subscriptions, seat quotas, and invoices become associated with organizations rather than individual users.
Does Laravel Spark support one-time charges?
No. Modern Spark is specifically optimized for recurring subscription models and seat-based licensing. If your application relies primarily on one-time checkout purchases or digital cart flows, you should implement Laravel Cashier or raw Stripe Checkout directly.
How does Spark handle user authentication?
Spark does not manage authentication. It expects your application to handle authentication using packages such as Laravel Breeze, Jetstream, Fortify, or custom guards. Spark simply protects its billing portal route behind your application web and auth middleware.
Laravel Spark provides a battle-tested blueprint for recurring SaaS billing, eliminating the need to build and maintain custom payment screens, receipt download generators, and card update forms. By treating billing as an isolated domain layer rather than tightly coupling it with your application core, your team can maintain continuous compliance and focus on core product features.
When adopting Spark, establish a solid foundation: index your subscription tables properly, handle webhooks via queued background jobs, decouple authorization checks from gateway APIs, and mock billing states inside your automated test suite. This technical rigor ensures that your billing architecture remains resilient as your subscriber base scales.