Grow your revenue streams by offering add-on subscriptions in your Laravel Spark app. Laravel Spark is excellent out of the box for a single subscription per team, but many SaaS products need to offer optional extras — additional integrations, storage upgrades, or premium features that teams can opt into. This guide shows you how to extend Spark to support exactly that.

The Problem with Spark's Default Subscription Model

Out of the box, Laravel Spark supports one subscription per billable entity (user or team). This works perfectly for tiered plans — Basic, Pro, Enterprise — but falls short when you want to offer optional add-ons on top of a base plan.

The good news: Spark is built on Laravel Cashier, and Cashier supports multiple subscriptions per customer since version 7. We just need to wire it up correctly.

Designing the Data Model

First, let's think about what an "add-on" looks like. An add-on has:

  • A name and description shown to users
  • A Stripe plan ID for billing
  • A monthly price
  • A slug used as the subscription name in Cashier

Create the migration:

Schema::create('addons', function (Blueprint $table) {
    $table->increments('id');
    $table->string('name');
    $table->string('slug')->unique();
    $table->text('description')->nullable();
    $table->string('stripe_plan');
    $table->decimal('price', 8, 2);
    $table->timestamps();
});

Linking Add-ons to Teams

Teams can have multiple active add-ons. We'll use the existing subscriptions table that Spark/Cashier creates, but we need to be able to distinguish add-on subscriptions from the primary plan.

// Add to Team model
public function addonSubscriptions()
{
    return $this->hasMany(Subscription::class)->whereIn(
        'name', Addon::pluck('slug')
    );
}

public function hasAddon(Addon $addon): bool
{
    return $this->subscribed($addon->slug);
}

Using the add-on's slug as the subscription name is the key insight here. Cashier uses the subscription name to identify which subscription you're referring to, so as long as your slugs are unique, you can have as many concurrent subscriptions as you need.

The Addon Model

Create a clean Eloquent model with some useful helpers:

class Addon extends Model
{
    protected $fillable = ['name', 'slug', 'description', 'stripe_plan', 'price'];

    public function getFormattedPriceAttribute(): string
    {
        return '$' . number_format($this->price, 2) . '/mo';
    }

    public function isInstalledFor(Team $team): bool
    {
        return $team->subscribed($this->slug);
    }
}

The Add-ons Marketplace Page

Build a simple marketplace view where teams can browse and install add-ons:

// AddonController
public function index()
{
    $addons = Addon::all()->map(function ($addon) {
        $addon->installed = $addon->isInstalledFor(Auth::user()->currentTeam);
        return $addon;
    });

    return view('addons.index', compact('addons'));
}

What's Next

In Part 2, we'll build the installation flow — the modal UI, the API endpoint, Stripe token handling, and the webhook listeners that keep everything in sync when subscriptions are renewed or cancelled.