
Migrating from Wave to Laravel Spark
Upgrade from the free Wave boilerplate to Laravel's official Spark billing solution for a more polished subscription management experience.
Prerequisites
- •Laravel 10+ experience
- •Understanding of your current Wave implementation
- •Composer and npm familiarity
Migrating from Wave to Laravel Spark
Wave (by DevDojo) is a fantastic free Laravel SaaS starter. Laravel Spark is the official billing solution from the Laravel team. This guide helps you migrate when you need Spark's polished billing portal and official support.
Why Migrate to Spark?#
Choose Spark when you need:
- Official Laravel team support and updates
- Polished, modern billing portal UI
- Seamless Laravel ecosystem integration
- Per-seat billing or metered billing
- Multiple subscription types
Stay with Wave if:
- You need the complete SaaS scaffold (not just billing)
- Voyager admin panel is essential
- Budget is a primary concern
- Wave's features meet your needs
What Spark Provides vs. Wave#
| Feature | Wave | Laravel Spark |
|---|---|---|
| Billing Portal | Custom/Basic | Polished, official |
| Admin Panel | Voyager | Not included |
| User Profiles | Included | Minimal |
| Themes | Built-in | Not included |
| Teams | Basic | Not in Spark Solo |
| Price | Free | $99-199 |
Important: Spark is primarily a billing solution. You'll keep your own app structure and integrate Spark for subscription management.
Step 1: Purchase and Install Spark#
# Add Spark to your composer repositories
composer config repositories.spark '{"type": "composer", "url": "https://spark.laravel.com"}'
# Install Spark (Stripe version)
composer require laravel/spark-stripe
# Publish Spark assets and config
php artisan spark:install
Step 2: Understand the Architecture Difference#
Wave Architecture:
├── User
│ ├── has roles (via Voyager)
│ ├── has subscription (via Paddle)
│ └── belongs to plan
└── Built-in themes, admin, etc.
Spark Architecture (Solo):
├── User (your existing model)
│ └── uses Billable trait
│ ├── Subscriptions
│ ├── Invoices
│ └── Payment Methods
└── Spark manages billing portal only
Step 3: Prepare Your User Model#
Remove Wave Billing Logic#
// app/Models/User.php
// Before (Wave)
use Wave\User as WaveUser;
class User extends WaveUser
{
// Wave methods inherited
}
// After (with Spark)
use Illuminate\Foundation\Auth\User as Authenticatable;
use Laravel\Spark\Billable;
class User extends Authenticatable
{
use Billable;
// Your custom attributes
protected $fillable = [
'name',
'email',
'password',
'avatar',
];
}
Step 4: Configure Spark Plans#
// config/spark.php
return [
'billables' => [
'user' => [
'model' => User::class,
'plans' => [
[
'name' => 'Starter',
'short_description' => 'For individuals',
'monthly_id' => env('SPARK_STARTER_MONTHLY_PRICE_ID'),
'yearly_id' => env('SPARK_STARTER_YEARLY_PRICE_ID'),
'features' => [
'5 Projects',
'Basic Support',
'1GB Storage',
],
],
[
'name' => 'Professional',
'short_description' => 'For power users',
'monthly_id' => env('SPARK_PRO_MONTHLY_PRICE_ID'),
'yearly_id' => env('SPARK_PRO_YEARLY_PRICE_ID'),
'features' => [
'Unlimited Projects',
'Priority Support',
'100GB Storage',
'API Access',
],
],
],
],
],
];
Step 5: Migrate Subscription Data#
Export Wave Subscriptions#
// database/seeders/MigrateWaveSubscriptions.php
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
class MigrateWaveSubscriptions extends Seeder
{
public function run()
{
// Wave stores subscriptions differently
$waveSubscriptions = DB::table('subscriptions')->get();
foreach ($waveSubscriptions as $sub) {
$user = User::find($sub->user_id);
if (!$user) continue;
// Map Wave plan to Spark plan
$sparkPriceId = $this->mapPlanToSparkPrice($sub->plan_id);
if ($sparkPriceId) {
// Create new subscription in Stripe format
// Note: You may need to sync with Stripe
$user->subscriptions()->create([
'name' => 'default',
'stripe_id' => $sub->paddle_id, // Different provider!
'stripe_status' => $this->mapStatus($sub->status),
'stripe_price' => $sparkPriceId,
'quantity' => 1,
'trial_ends_at' => $sub->trial_ends_at,
'ends_at' => $sub->ends_at,
]);
}
}
}
private function mapPlanToSparkPrice($wavePlanId)
{
$mapping = [
1 => env('SPARK_STARTER_MONTHLY_PRICE_ID'),
2 => env('SPARK_PRO_MONTHLY_PRICE_ID'),
// Add your plan mappings
];
return $mapping[$wavePlanId] ?? null;
}
private function mapStatus($waveStatus)
{
return match($waveStatus) {
'active' => 'active',
'trialing' => 'trialing',
'past_due' => 'past_due',
'cancelled' => 'canceled',
default => 'incomplete',
};
}
}
Handle Payment Provider Switch (Paddle → Stripe)#
⚠️ Important: Wave typically uses Paddle, while Spark uses Stripe. You cannot directly migrate subscriptions between providers.
Options:
- Grandfather existing subscriptions: Keep Paddle active for existing users, use Spark/Stripe for new users
- Migrate customers: Ask users to re-subscribe through Stripe
- Cancel and credit: Cancel Paddle subs, credit remaining time in Stripe
// Option 1: Dual-provider support
public function subscribed($plan = null)
{
// Check both providers
return $this->hasActivePaddleSubscription()
|| $this->hasActiveSparkSubscription($plan);
}
public function hasActivePaddleSubscription()
{
// Legacy Wave check
return DB::table('wave_subscriptions')
->where('user_id', $this->id)
->where('status', 'active')
->exists();
}
public function hasActiveSparkSubscription($plan = null)
{
// Spark check
return $this->subscribed($plan);
}
Step 6: Update Routes#
Remove Wave Billing Routes#
// routes/web.php
// Before (Wave routes)
Wave::routes();
// After (Spark routes)
Route::middleware(['auth'])->group(function () {
// Spark provides its own billing routes at /billing
// Your custom routes
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::resource('projects', ProjectController::class);
});
Step 7: Update Middleware#
Wave Subscription Check#
// app/Http/Middleware/SubscriptionMiddleware.php
// Before (Wave)
public function handle($request, Closure $next)
{
if (!auth()->user()->isSubscribed()) {
return redirect('/pricing');
}
return $next($request);
}
// After (Spark)
public function handle($request, Closure $next)
{
$user = $request->user();
// Spark's subscription check
if (!$user->subscribed()) {
return redirect('/billing');
}
// Optional: Check for specific plan
if (!$user->subscribedToPrice(env('SPARK_PRO_MONTHLY_PRICE_ID'))) {
return redirect('/billing')->with('error', 'Pro plan required');
}
return $next($request);
}
Register in Kernel#
// app/Http/Kernel.php
protected $middlewareAliases = [
// ...
'subscribed' => \App\Http\Middleware\SubscriptionMiddleware::class,
];
Step 8: Update Views#
Replace Wave Billing UI#
{{-- Before (Wave billing in sidebar) --}}
@if(auth()->user()->onTrial())
<div class="trial-notice">
Trial ends {{ auth()->user()->trial_ends_at->diffForHumans() }}
</div>
@endif
{{-- After (Link to Spark billing) --}}
<a href="/billing" class="btn btn-primary">
Manage Subscription
</a>
@if(auth()->user()->onTrial())
<div class="trial-notice">
Trial ends {{ auth()->user()->trialEndsAt()->diffForHumans() }}
</div>
@endif
Pricing Page#
{{-- resources/views/pricing.blade.php --}}
<div class="pricing-grid">
@foreach(config('spark.billables.user.plans') as $plan)
<div class="pricing-card">
<h3>{{ $plan['name'] }}</h3>
<p>{{ $plan['short_description'] }}</p>
<ul>
@foreach($plan['features'] as $feature)
<li>{{ $feature }}</li>
@endforeach
</ul>
@auth
<a href="/billing" class="btn">
{{ auth()->user()->subscribed() ? 'Manage' : 'Subscribe' }}
</a>
@else
<a href="/register" class="btn">Get Started</a>
@endauth
</div>
@endforeach
</div>
Step 9: Handle Webhooks#
// routes/api.php
Route::post('/stripe/webhook', [WebhookController::class, 'handle']);
// app/Http/Controllers/WebhookController.php
use Laravel\Cashier\Http\Controllers\WebhookController as CashierWebhookController;
class WebhookController extends CashierWebhookController
{
public function handleCustomerSubscriptionCreated($payload)
{
parent::handleCustomerSubscriptionCreated($payload);
// Custom logic after subscription
$user = $this->getUserByStripeId($payload['data']['object']['customer']);
if ($user) {
// Send welcome email, provision resources, etc.
event(new UserSubscribed($user));
}
}
public function handleCustomerSubscriptionDeleted($payload)
{
parent::handleCustomerSubscriptionDeleted($payload);
// Custom cleanup logic
$user = $this->getUserByStripeId($payload['data']['object']['customer']);
if ($user) {
// Downgrade features, send retention email, etc.
event(new UserCanceled($user));
}
}
}
Step 10: Remove Wave Dependencies#
# Remove Wave package
composer remove devdojo/wave
# Remove Voyager if not needed elsewhere
composer remove tcg/voyager
# Clean up Wave migrations
rm database/migrations/*_create_wave_*.php
# Remove Wave config
rm config/wave.php
# Update composer
composer update
Clean Up Database#
// database/migrations/XXXX_remove_wave_tables.php
public function up()
{
Schema::dropIfExists('wave_key_values');
Schema::dropIfExists('wave_plans');
Schema::dropIfExists('wave_subscriptions');
// Keep user data!
}
Common Challenges#
1. Voyager Admin Dependency#
Problem: Wave includes Voyager for admin functionality.
Solution: Replace with Laravel Nova, Filament, or custom admin:
# Option: Install Filament as replacement
composer require filament/filament
php artisan filament:install
2. User Roles from Wave#
Problem: Wave includes role management.
Solution: Use Spatie Laravel Permission:
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
// Migrate Wave roles
$waveRoles = DB::table('roles')->get();
foreach ($waveRoles as $role) {
\Spatie\Permission\Models\Role::create(['name' => $role->name]);
}
3. Theme System#
Problem: Wave includes a theme system you might be using.
Solution: Extract your current theme to standalone Blade templates:
# Copy Wave theme to your views
cp -r vendor/devdojo/wave/resources/views/themes/YOUR_THEME resources/views/
Testing Checklist#
- New users can register
- Existing users can log in
- Spark billing portal accessible at
/billing - Subscription checkout works
- Webhook events processed correctly
- Subscription status checks work throughout app
- Legacy Paddle subscriptions still honored (if applicable)
- Admin functionality working (if replaced)
Timeline Estimate#
| Phase | Estimated Time |
|---|---|
| Spark Installation | 1 hour |
| User Model Updates | 2 hours |
| Subscription Migration | 4 hours |
| Route/Middleware Updates | 2 hours |
| View Updates | 3 hours |
| Webhook Setup | 2 hours |
| Voyager Replacement | 3 hours |
| Testing | 1 hour |
| Total | ~18 hours |
Conclusion#
Migrating from Wave to Laravel Spark simplifies your billing infrastructure by using Laravel's official solution. The main challenge is the payment provider switch (Paddle to Stripe) and replacing Wave's additional features like Voyager admin.
Spark focuses specifically on billing, so you'll need to evaluate whether Wave's other features (admin, themes, roles) need replacement solutions. For apps primarily needing better billing, Spark is an excellent upgrade path.
Not sure which boilerplate to choose?
Take our 2-minute quiz and get personalized recommendations.
Take the Quiz