Codemason Blog | Codemason
Codemason Blog | Codemason

Codemason is a cloud platform for hosting apps without the hassle. Our superpower is making complex things simple. Build, scale and manage hundreds of sites with ease on Codemason.

Ben Magg
Author

Founder of Codemason

Share


Our Newsletter


Subscribe to our newsletter to get the latest on building, deploying, managing and scaling great apps and businesses.

Tags


Twitter


Codemason Blog | Codemason

Extend Laravel Spark to support add-on subscriptions (Part 1)

Grow your revenue streams by offering add-on subscriptions in your Laravel Spark app. This guide will show you how to extend Laravel Spark to support multiple subscriptions.

Ben MaggBen Magg

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">/&nbsp;{{ 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

addon-plans

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.

addon-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>

Head on over to Part 2 to see how to "Deliver the Goods" (charging people and allowing users to manage their add-on subscriptions)


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!

Ben Magg
Author

Ben Magg

Founder of Codemason

Comments