This post will walk you through how you can extend Laravel Spark to support multiple subscriptions. Extending Spark this way is useful if you’d like to offer a service or product as an add-on to your existing Spark plans.
We use this approach at Codemason to manage the subscriptions for our add-ons. Codemason is an end-to-end cloud platform and with Codemason Add-on’s, users are able to launch production-grade apps with a click.
By the end of this guide, your Spark app will:
- Be able to define “add-on” subscriptions
- Support multiple plans for each add-on
- Allow users to subscribe and cancel their subscription to an add-on plan
- Have implemented this extended functionality in a similar style to existing Spark functionality
This guide has been written for a Spark v5.0 project using team billing.
To implement this functionality on a Spark project that’s not using team billing you will need to make some minor changes.
Defining an add-on
The first thing we’ll do is create an Addon
class. This will let us create add-ons.
<?php
namespace App;
class Addon
{
/**
* The add-on's unique ID.
*
* @var string
*/
public $id;
/**
* The add-on's displayable name.
*
* @var string
*/
public $name;
/**
* Short description for the add-on.
*
* @var string
*/
public $description;
/**
*
* @param \stdClass|array|null $parameters
*/
public function __construct($parameters = null)
{
if (!$parameters) {
return;
}
if ($parameters instanceof \stdClass) {
$parameters = get_object_vars($parameters);
}
$this->build($parameters);
}
/**
* Build by filling the public properties with values from array.
*
* @param array $parameters
*/
public function build(array $parameters)
{
foreach ($parameters as $property => $value) {
$property = camel_case($property);
if(property_exists($this, $property) && (new \ReflectionProperty($this, $property))->isPublic()) {
$this->$property = $value;
}
}
}
}
Defining an add-on plan
Now we’ll extend Spark's Plan
class by creating an AddonPlan
class. This will let us define an AddonPlan
in a similar way to a standard Spark Plan
in our SparkServiceProvider
.
<?php
namespace App;
use Laravel\Spark\Plan;
class AddonPlan extends Plan
{
/**
* The plan's add-on.
*
* @var string
*/
public $addon;
/**
* Create a new plan instance.
*/
public function __construct($addon, $planName, $stripeId)
{
$this->id = $stripeId;
$this->name = $planName;
$this->addon = $addon;
}
/**
* Get the add-on for this plan.
*
* @return Addon
*/
public function addon()
{
return Spark::addon($this->addon);
}
}
Telling Spark about your add-ons
We need a way of defining an add-on and the associated add-on plans inside Spark. We’ll do this by creating Addon
and AddonPlan
objects and extending the \Laravel\Spark\Spark
class.
Create app/Spark.php
<?php
namespace App;
class Spark extends \Laravel\Spark\Spark
{
use Traits\ManagesAvailableAddons,
Traits\ManagesAvailableAddonPlans;
}
Create app/Traits/ManagesAvailableAddons.php
<?php
namespace App\Traits;
use App\Addon;
trait ManagesAvailableAddons
{
/**
* The available add-ons.
*
* @var array
*/
public static $addons = [];
/**
*
* Create a new add-on instance.
*
* @param Addon $addon
* @return void
*/
public static function addon(Addon $addon)
{
static::$addons[] = $addon;
}
/**
* Get the available add-ons
*
* @return \Illuminate\Support\Collection
*/
public static function addons()
{
return collect(static::$addons);
}
/**
* Find a specific add-on by the ID.
*
* @param string $id
* @return mixed
*/
public static function findAddonById($id)
{
return static::addons()->where('id', $id)->first();
}
}
Create app/Traits/ManagesAvailableAddonPlans.php
<?php
namespace App\Traits;
use App\AddonPlan;
trait ManagesAvailableAddonPlans
{
/**
* All of the add-on plans defined for the application.
*
* @var array
*/
public static $addonPlans = [];
/**
* Create a add-on plan instance.
*
* @param string|Addon $addon
* @param string $planName
* @param string $stripeId
* @return \Laravel\Spark\Plan
*/
public static function addonPlan($addon, $planName, $stripeId)
{
static::$addonPlans[] = $plan = new AddonPlan($addon, $planName, $stripeId);
return $plan;
}
/**
* Gets all the add-on plans defined for the application.
*
* @return \Illuminate\Support\Collection
*/
public static function allAddonPlans()
{
return collect(static::$addonPlans)->map(function ($plan) {
$plan->type = 'addonPlan';
return $plan;
});
}
/**
* Get the plans defined for an add-on
*
* @param string $addon
* @return \Illuminate\Support\Collection
*/
public static function addonPlans($addon)
{
return collect(static::allAddonPlans()->where('addon', $addon)->all());
}
/**
* Get a comma delimited list of active add-on plan IDs.
*
* @return string
*/
public static function activeAddonPlanIdList()
{
return implode(',', static::allAddonPlans()->filter(function ($plan) {
return $plan->active;
})->pluck('id')->toArray());
}
/**
* Find a specific add-on plan by the ID.
*
* @param string $id
* @return mixed
*/
public static function findAddonPlanById($id)
{
return static::allAddonPlans()->where('id', $id)->first();
}
}
Make sure you switch the Spark
class in SparkServiceProvider
to \App\Spark
.
Tell Spark about the available add-ons and add-on plans in our SparkServiceProvider
, in the same style as a normal Spark Plan.
<?php
namespace App\Providers;
use App\Spark;
use App\Addon;
use Laravel\Spark\Providers\AppServiceProvider as ServiceProvider;
class SparkServiceProvider extends ServiceProvider
{
// ...
/**
* Finish configuring Spark for the application.
*
* @return void
*/
public function booted()
{
// ...
// SocketCluster
Spark::addon(new \App\Addon([
'id' => 'socketcluster',
'name' => 'SocketCluster',
'description' => "A scalable framework for real-time apps and services.",
]));
// SocketCluster - Plan: Hobby
Spark::addonPlan('socketcluster', 'Hobby', 'socketcluster:hobby')
->price(19)
->trialDays(15)
->features([
"Powered by 512mb RAM",
"No complicated setup",
"Email support"
])
->attributes([
'description' => "Single 512mb SocketCluster instance, perfect for development",
]);
// ...
}
}
In the above example, we've defined a SocketCluster add-on and a SocketCluster Hobby plan. You can define as many add-ons and plans this way as you want. For example, I could also create a "Managed MySQL" add-on and offer a managed MySQL service as an additional, optional, subscription to my app's users.
Displaying available add-on plans
The bare bones are in place for defining add-ons and add-on plans. Now it’s time to start looking at how all of this will fit together with the rest of our Spark app.
Create a route to view our add-on (in routes/web.php
). We'll implement addon.blade.php
later on, once we've finished implementing all the components we'll also need.
<?php
Route::get('addons/{addon}', function($addon) {
return view('addon')
->with('addon', \App\Spark::findAddonById($addon));
});
Create an API endpoint to list the add-on plans (in routes/api.php
)
<?php
Route::get('addons/{addon}/plans', function($addon) {
return response()->json(\App\Spark::addonPlans($addon));
});
Create a Vue component that gets the add-on plans
<template>
<div class="list-group price-list m-b-sm">
<a v-for="plan in plans" href="#" v-on:click.prevent="selectPlan(plan)" class="list-group-item" :class="{'active': selectedPlan == plan}">
<div class="col-xs-9">
<h4 class="list-group-item-heading">{{ plan.name }}</h4>
<p class="list-group-item-text">{{ plan.attributes.description }}</p>
</div>
<div class="col-xs-3 plan">
<div class="price">${{ plan.price }}</div>
<div class="schedule">/ {{ plan.interval == "monthly" ? "month" : "year" }}</div>
</div>
<div class="clearfix"></div>
</a>
</div>
</template>
<script>
export default {
props: ['addon'],
/**
* The component's data.
*/
data: function () {
return {
selectedPlan: null,
plans: []
};
},
/**
* Prepare the component.
*/
mounted: function() {
this.getPlans();
},
methods: {
/**
* Get the active plans for the add-on.
*/
getPlans: function () {
axios.get('/api/addons/' + this.addon.id + '/plans')
.then(response => {
this.plans = response.data;
this.selectPlan(_.first(_.where(response.data, { default: true })));
});
},
/**
* Select an add-on plan
*/
selectPlan: function(plan) {
// Change the plan
this.selectedPlan = plan;
// Notify other components of the selected plan
Bus.$emit('addonPlanSelected', plan);
}
}
}
</script>
Which will output something like this
Choosing an add-on plan
In the addon-plans
component when the user selects a plan, we emit an addonPlanSelected
event. Hook into this to populate our plan preview.
Create a Vue component for the add-on plan preview
<template>
<div v-if="plan" class="panel panel-default panel-price">
<div class="panel-body">
<div class="media">
<div class="media-body">
<small class="text-muted font-weight-normal">{{ addon.name }}</small>
<h4 class="plan-name">{{ plan.name }}</h4>
</div>
<div class="media-right">
<img class="media-object" :src="addon.logo" alt="SocketCluster" width="45">
</div>
</div>
<div class="row">
<div class="col-md-12">
<p>{{ plan.attributes.description }}</p>
</div>
</div>
<div class="row">
<div class="col-md-12">
<ul class="plan-features">
<li v-for="feature in plan.features"><i class="fa fa-check"></i> {{ feature }}</li>
</ul>
</div>
</div>
<div class="row m-t-xs">
<div class="col-md-12">
<div class="plan-price">
<h4 class="price">
<span class="pull-left">${{ plan.price }} <small>/ {{ plan.interval == "monthly" ? "month" : "year" }}</small></span>
<span v-if="plan.trialDays > 0" class="pull-right"><small><b>Free {{ plan.trialDays }} Day Trial</b></small></span>
<div class="clearfix"></div>
</h4>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12 m-t-md">
<button v-if="spark.userId" @click="openProvisionerModal" class="btn btn-success btn-lg col-xs-12">Install</button>
<a v-else href="/login" class="btn btn-success btn-lg col-xs-12">Login to Install</a>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['addon', 'teams', 'currentTeam'],
/**
* The component's data.
*/
data: function () {
return {
plan: null,
};
},
/**
* Prepare the component.
*/
mounted: function() {
var self = this;
// Listen for a plan being selected
Bus.$on('addonPlanSelected', function (plan) {
self.plan = plan;
});
},
methods: {
/**
* Open the provision add-on modal
*/
openProvisionerModal: function() {
Bus.$emit('addonPlanInstall', this.addon, this.plan);
},
}
}
</script>
About Codemason
We ❤️ Laravel Spark. We used Laravel Spark for Codemason so we could focus on building functionality our users loved, instead of writing boilerplate code. Codemason is the Laravel Spark of servers/hosting 🙃
Working on a Laravel Spark project? People who use Spark know how important their time is. Save yourself hours of fighting with servers by using Codemason.
Don't forget to use the coupon SPARK-3-FREE
to get 3 months free access!