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

The Ultimate Guide to Building a Shopify App with Laravel

The Ultimate Guide to Building a Shopify App with Laravel. A thorough and up-to-date walk through of building a Shopify app with Laravel!

Ben MaggBen Magg

This post will walk you through building a Shopify app with Laravel.

There seems to be a profound lack of info about building a Shopify app with PHP. This guide will rectify that.

So, strap yourself in and grab yourself a drink or two because this is going to be the much needed, thorough and up-to-date walk through of building a Shopify app with Laravel! 🙌

The full source for this guide is available on Github

Why build apps for Shopify?

Shopify is an incredible platform. Each year more and more people use Shopify to run their profitable businesses/store. Their continued growth means there are more stores looking for apps to simplify their operations, increase their sales and achieve their goals.

The Shopify app store is a bit of a niche market in the sense that it's not as oversaturated as the Wordpress Plugin market and a lot more SaaS friendly. A Shopify app is an excellent way to start a side business.

Building for a platform with its own app store or market place is a great way to launch a product to an existing audience and leverage their established marketing/app discovery channels to grow your product.

Prepare your Shopify app

Let's first create an app on via the Shopify Partners dashboard.

Screen-Shot-2018-08-01-at-3.02.11-pm

Once created, you will immediately see you app API keys. We will need those later

Screen-Shot-2018-08-01-at-3.02.59-pm

Finally, make sure you add our soon to be created Shopify OAuth callback endpoint (http://localhost:8081/login/shopify/callback) to the whitelisted redirection URLs in App Setup > URLs

Screen-Shot-2018-08-09-at-10.32.43-am

Prepare your Laravel app

First we will need to spin up a new Laravel app. We're going to do this from scratch and it's going to be surprisingly easy. You'll see!

Create your new Laravel app

laravel new my-shopify-app

Next, since this is a Shopify app, we'll want let people sign in with their Shopify account. Laravel makes this a breeze with Socialite.

Install Socialite and the Shopify Socialite provider

composer require laravel/socialite
composer require socialiteproviders/shopify

Add \SocialiteProviders\Manager\ServiceProvider::class to your providers[] array in config/app.php

'providers' => [
    \SocialiteProviders\Manager\ServiceProvider::class, 
];

Add the SocialiteWasCalled event and [email protected] listener to your listen[] array in app/Providers/EventServiceProvider

protected $listen = [
    \SocialiteProviders\Manager\SocialiteWasCalled::class => [
        'SocialiteProviders\\Shopify\\[email protected]',
    ],
];

Update your .env file with your Shopify app credentials. You can access them from the Shopify Partner dashboard in App Setup > App credentials.

SHOPIFY_KEY=481652035492843799ae72ec67f1dd22
SHOPIFY_SECRET=XXXXXXXXXXXXXXXXXXXXXXXX
SHOPIFY_REDIRECT=http://localhost:8081/login/shopify/callback

Now we can update config/services.php to give the Shopify Socialite provider the config values it needs

'shopify' => [
    'client_id' => env('SHOPIFY_KEY'),
    'client_secret' => env('SHOPIFY_SECRET'),
    'redirect' => env('SHOPIFY_REDIRECT'),
],

Migrations and Models

For our implementation, we're going to allow users to register multiple Shopify stores under their account. We will need to store each Shopify store and the access key Socialite gives us for the Shopify user... So let's run some migrations and make some new models

First the migrations (we've put them all into one single migration for simplicity).

Create your migration

php artisan make:migration create_stores_table

Add the following up method

public function up()
{
    Schema::create('stores', function (Blueprint $table) {
        $table->increments('id');
        $table->string('name');
        $table->string('domain');
        $table->timestamps();
    });


    Schema::create('store_users', function (Blueprint $table) {
        $table->integer('store_id');
        $table->integer('user_id');
        $table->unique(['store_id', 'user_id']);
    });


    Schema::create('store_charges', function (Blueprint $table) {
        $table->increments('id');
        $table->unsignedInteger('user_id');
        $table->string('name');
        $table->string('plan');
        $table->integer('quantity');
        $table->timestamp('trial_ends_at')->nullable();
        $table->timestamp('ends_at')->nullable();
        $table->timestamps();
    });
    
    Schema::create('user_providers', function (Blueprint $table) {
        $table->increments('id');
        $table->integer('user_id')->unsigned();
        $table->string('provider');
        $table->string('provider_user_id');
        $table->string('provider_token')->nullable();
        $table->timestamps();
    });
}

Add the following down method

public function down()
{
    Schema::dropIfExists('stores');
    Schema::dropIfExists('stores_users');
    Schema::dropIfExists('store_charges');
    Schema::dropIfExists('user_providers');
}

Then create the Store model.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Store extends Model
{

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'domain'
    ];

    /**
     * Get all of the users that belong to the store.
     */
    public function users()
    {
        return $this->belongsToMany(
            'App\User', 'store_users', 'store_id', 'user_id'
        );
    }

}

Now create the UserProviders model

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class UserProvider extends Model
{

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'user_id', 'provider', 'provider_user_id', 'provider_token',
    ];

}

Finally, update the User model with our new relations

/**
 * Get the stores for the user
 */
public function stores()
{
    return $this->belongsToMany('App\Store', 'store_users');
}

/**
 * Get the providers for the user
 */
public function providers()
{
    return $this->hasMany('App\UserProvider');
}

With these additional models, we'll be able to authorise the user via Socialite (using OAuth), store the access token and save the Shopify store they're connecting.

Authentication and Registration

Let's implement our authentication system for our app. Thankfully Laravel makes it easy to scaffold an entire authentication system with two commands

php artisan make:auth
php artisan migrate

Now we will create a controller to handle our Socialite OAuth authentications. Add the following to app/Http/Auth/LoginShopifyController

<?php

namespace App\Http\Controllers\Auth;

use Socialite;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class LoginShopifyController extends Controller
{

    /**
     * Redirect the user to the GitHub authentication page.
     *
     * @return \Illuminate\Http\Response
     */
    public function redirectToProvider(Request $request)
    {

        $this->validate($request, [
            'domain' => 'string|required'
        ]);

        $config = new \SocialiteProviders\Manager\Config(
            env('SHOPIFY_KEY'),
            env('SHOPIFY_SECRET'),
            env('SHOPIFY_REDIRECT'),
            ['subdomain' => $request->get('domain')]
        );

        return Socialite::with('shopify')
            ->setConfig($config)
            ->scopes(['read_products','write_products'])
            ->redirect();

    }

    /**
     * Obtain the user information from GitHub.
     *
     * @return \Illuminate\Http\Response
     */
    public function handleProviderCallback()
    {

        $shopifyUser = Socialite::driver('shopify')->user();

        // Create user
        $user = User::firstOrCreate([
            'name' => $shopifyUser->name,
            'email' => $shopifyUser->email,
            'password' => '',
        ]);

        // Store the OAuth Identity
        UserProvider::firstOrCreate([
            'user_id' => $user->id,
            'provider' => 'shopify',
            'provider_user_id' => $shopifyUser->id,
            'provider_token' => $shopifyUser->token,
        ]);

        // Create shop
        $shop = Shop::firstOrCreate([
            'name' => $shopifyUser->name,
            'domain' => $shopifyUser->nickname,
        ]);

        // Attach shop to user
        $shop->users()->syncWithoutDetaching([$user->id]);

        // Login with Laravel's Authentication system
        Auth::login($user, true);

        return redirect('/home');

    }

}

Then add the new OAuth authentication routes to your web.php routes file

Route::get('login/shopify', 'Auth\[email protected]');
Route::get('login/shopify/callback', 'Auth\[email protected]');

All that's left is to implement the new login and register views for to support our Shopify Socialite auth capabilities

<div class="row justify-content-center">
    <div class="col-md-4">
        <div class="card">
            <div class="card-body">
                <div class="text-center">
                    <h3>Create a new account</h3>
                    <p class="text-muted">Get our 30-day free trial and start increasing your sales today</p>
                </div>
                <hr class="mb-4">
                <form method="GET" action="{{ route('login.shopify') }}" aria-label="{{ __('Register') }}">
                    <div class="form-group">
                        <label for="domain">Domain</label>

                        <div class="input-group mb-3">
                            <input id="domain" type="text" class="form-control{{ $errors->has('domain') ? ' is-invalid' : '' }}" name="domain" value="{{ old('domain') }}" placeholder="yourshop" aria-describedby="myshopify" required autofocus>
                            <div class="input-group-append">
                                <span class="input-group-text" id="myshopify">myshopify.com</span>
                            </div>
                        </div>

                        <button type="submit" class="btn btn-primary btn-block">Continue</button>
                    </div>
                </form>
            </div>
        </div>

        <div class="text-center mt-3">
            <p class="text-center text-muted">Already have an account? <a href="{{ route('login') }}">Sign in here</a></p>
        </div>
    </div>
</div>

Screen-Shot-2018-08-01-at-6.26.04-pm

Congratulations! You can now sign into your app with your Shopify account 😎

Screen-Shot-2018-08-01-at-6.16.34-pm

Billing

Now users can login to our app with their Shopify account, let's implement some billing capabilities.

We're going to perform these checks using Laravel Middleware. This will let us limit access to routes based on the user's subscription status.

To implement this, we're going to work with the Shopify API and add a mechanism to track the subscription status of a store.

Preparing your app for billing

First let's pull in a Shopify API library.

composer require bnmetrics/laravel-shopify-api

Register the service provider in app/config/app.php

"providers" => [
   // other providers...
   BNMetrics\Shopify\ShopifyServiceProvider::class,
   BNMetrics\Shopify\BillingServiceProvider::class,
]

Add the Shopify facade in your aliases array in app/config/app.php

"aliases" => [
   // other facades...
   'Shopify' => BNMetrics\Shopify\Facade\ShopifyFacade::class,
   'ShopifyBilling' => BNMetrics\Shopify\Facade\BillingFacade::class,
] 

Migrations and models

Create a new migration for our store_charges.

php artisan make:migration create_store_charges_table

Implement our up/down migrations

public function up()
{
    Schema::create('store_charges', function (Blueprint $table) {
        $table->increments('id');
        $table->unsignedInteger('user_id');
        $table->string('name');
        $table->string('plan');
        $table->integer('quantity');
        $table->integer('charge_type');
        $table->timestamp('trial_ends_at')->nullable();
        $table->timestamp('ends_at')->nullable();
        $table->timestamps();
    });
}

public function down()
{
    Schema::dropIfExists('store_charges');
}

Implement our Charge model. This model has helper methods for dealing with subscription (Charge::CHARGE_RECURRING) charges.

<?php

namespace App;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;

class Charge extends Model
{

    /**
     * Types of charges
     */
    const CHARGE_RECURRING = 1;
    const CHARGE_ONETIME = 2;
    const CHARGE_USAGE = 3;
    const CHARGE_CREDIT = 4;

    /**
     * The table associated with the model.
     *
     * @var string
     */
    protected $table = "store_charges";

    /**
     * The attributes that should be mutated to dates.
     *
     * @var array
     */
    protected $dates = [
        'trial_ends_at', 'ends_at',
        'created_at', 'updated_at',
    ];

    /**
     * Get the store the charge belongs to.
     */
    public function store()
    {
        return $this->belongsTo('App\Store');
    }

    /**
     * Checks if the charge is a test.
     *
     * @return bool
     */
    public function isTest()
    {
        return (bool) $this->test;
    }

    /**
     * Determine if the subscription is active, on trial, or within its grace period.
     *
     * @return bool
     */
    public function valid()
    {
        return $this->active() || $this->onTrial() || $this->onGracePeriod();
    }

    /**
     * Determine if the subscription is active.
     *
     * @return bool
     */
    public function active()
    {
        return is_null($this->ends_at) || $this->onGracePeriod();
    }

    /**
     * Determine if the subscription is no longer active.
     *
     * @return bool
     */
    public function cancelled()
    {
        return ! is_null($this->ends_at);
    }

    /**
     * Determine if the subscription is within its trial period.
     *
     * @return bool
     */
    public function onTrial()
    {
        if (! is_null($this->trial_ends_at)) {
            return Carbon::now()->lt($this->trial_ends_at);
        } else {
            return false;
        }
    }

    /**
     * Determine if the subscription is within its grace period after cancellation.
     *
     * @return bool
     */
    public function onGracePeriod()
    {
        if (! is_null($endsAt = $this->ends_at)) {
            return Carbon::now()->lt(Carbon::instance($endsAt));
        } else {
            return false;
        }
    }

}

Update the Store model with new methods for interacting with subscriptions

/**
 * Determine if the store is on trial.
 *
 * @param  string  $subscription
 * @param  string|null  $plan
 * @return bool
 */
public function onTrial($subscription = 'default', $plan = null)
{
    if (func_num_args() === 0 && $this->onGenericTrial()) {
        return true;
    }

    $subscription = $this->subscription($subscription);

    if (is_null($plan)) {
        return $subscription && $subscription->onTrial();
    }

    return $subscription && $subscription->onTrial() &&
    $subscription->stripe_plan === $plan;
}

/**
 * Determine if the store is on a "generic" trial at the model level.
 *
 * @return bool
 */
public function onGenericTrial()
{
    return $this->trial_ends_at && \Carbon\Carbon::now()->lt($this->trial_ends_at);
}

/**
 * Determine if the store has a given subscription.
 *
 * @param  string  $subscription
 * @param  string|null  $plan
 * @return bool
 */
public function subscribed($subscription = 'default', $plan = null)
{
    $subscription = $this->subscription($subscription);

    if (is_null($subscription)) {
        return false;
    }

    if (is_null($plan)) {
        return $subscription->valid();
    }

    return $subscription->valid() &&
    $subscription->shopify_plan === $plan;
}

/**
 * Get a subscription instance by name.
 *
 * @param  string  $subscription
 * @return \App\Charge|null
 */
public function subscription($subscription = 'default')
{
    return $this->subscriptions->sortByDesc(function ($value) {
        return $value->created_at->getTimestamp();
    })->first(function ($value) use ($subscription) {
        return $value->name === $subscription;
    });
}

/**
 * Get all of the subscriptions for the store.
 *
 * @return \Illuminate\Database\Eloquent\Collection
 */
public function subscriptions()
{
    return $this->hasMany('App\Charge')
        ->where('charge_type', Charge::CHARGE_RECURRING)
        ->orderBy('created_at', 'desc');
}

Checking subscription status

Since we're using middleware to check the subscription status, let's create the middleware

php artisan make:middleware VerifyStoreIsSubscribed

Register the middleware in app/Http/Kernel.php

protected $routeMiddleware = [
    // ...
    'subscribed' => \App\Http\Middleware\VerifyStoreIsSubscribed::class,
]; 

Time to implement the middleware. We are checking to see if the store is subscribed to a plan using our new subscription helper methods on the Store model. If the store is not subscribed, we redirect them to Shopify to approve the charge.

NOTE: Our implementation is going to include the storeId in the URL, so the middleware will retrieve and use that route parameter to fetch the store.

/**
 * Verify the incoming request's user has a subscription.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \Closure  $next
 * @param  string  $subscription
 * @param  string  $plan
 * @return \Illuminate\Http\Response
 */
public function handle($request, $next, $subscription = 'default', $plan = null)
{
    
    $store = Store::find($request->route()->parameter('storeId'));

    if ($this->subscribed($store, $subscription, $plan, func_num_args() === 2)) {
        return $next($request);
    }

    if($request->ajax() || $request->wantsJson()) {
        response('Subscription Required.', 402);
    }

    $user = auth()->user()->providers->where('provider', 'shopify')->first();
    $shopify = \Shopify::retrieve($store->domain, $user->provider_token);

    $options = [
        'name' => 'starter plan',
        'price' => '10',
        'trial_days' => 30,
        'return_url' => route('shopify.subscribe', ['storeId' => $store->id]),
    ];

    if(\App::environment('local')) {
        $options['test'] = true;
    }

    return \ShopifyBilling::driver('RecurringBilling')
        ->create($shopify, $options)
        ->redirect()
        ->with('user', $user);
}

/**
 * Determine if the given user is subscribed to the given plan.
 *
 * @param  \App\Store  $store
 * @param  string  $subscription
 * @param  string  $plan
 * @param  bool  $defaultSubscription
 * @return bool
 */
protected function subscribed($store, $subscription, $plan, $defaultSubscription)
{
    if (! $store) {
        return false;
    }

    return ($defaultSubscription && $store->onGenericTrial()) ||
            $store->subscribed($subscription, $plan);
}

With our new middleware, we can easily restrict access to our app based on their subscription status

Route::get('/store/{storeId}', function() {
    // Display the store dashboard
})->middleware(['auth', 'subscribed']);

Subscribed callback

When a user accepts a charge on Shopify, shopify redirects the user back to a redirect_url with the charge_id. From there you'll need to activate the charge and store the charge info.

Add this route to your routes/web.php file

Route::get('stores/{storeId}/shopify/subscribe', function(\Illuminate\Http\Request $request, $storeId) {


    $store = \App\Store::find($storeId);
    $user = auth()->user()->providers->where('provider', 'shopify')->first();
    $shopify = \Shopify::retrieve($store->domain, $user->provider_token);

    $activated = \ShopifyBilling::driver('RecurringBilling')
        ->activate($store->domain, $user->provider_token, $request->get('charge_id'));

    $response = array_get($activated->getActivated(), 'recurring_application_charge');

    \App\Charge::create([
        'store_id' => $store->id,
        'name' => 'default',
        'shopify_charge_id' => $request->get('charge_id'),
        'shopify_plan' => array_get($response, 'name'),
        'quantity' => 1,
        'charge_type' => \App\Charge::CHARGE_RECURRING,
        'test' => array_get($response, 'test'),
        'trial_ends_at' => array_get($response, 'trial_ends_on'),
    ]);

    return redirect('/home');

})->name('shopify.subscribe');

Webhooks

Shopify allows you to register webhooks in your app to recieve "notifications" about events relevant to a shop.

When an event occurs a POST request is sent to your app where it can handle it accordingly. For example, if you implemented an orders/create webhook, when an order is created on the store, Shopify would POST to your webhook.

There are plenty of webhooks available to tap into. You can find a full list here.

The key thing is to verify the webhook you recieve. We can do this by creating a webhook middleware

php artisan make:middleware VerifyWebhook

Implement the handle method

public function handle($request, Closure $next)
{

    $hmac = request()->header('x-shopify-hmac-sha256') ?: '';
    $shop = request()->header('x-shopify-shop-domain');
    $data = request()->getContent();

    // From https://help.shopify.com/api/getting-started/webhooks#verify-webhook
    $hmacLocal = base64_encode(hash_hmac('sha256', $data, env('SHOPIFY_SECRET'), true));
    if (!hash_equals($hmac, $hmacLocal) || empty($shop)) {
        // Issue with HMAC or missing shop header
        abort(401, 'Invalid webhook signature');
    }


    return $next($request);
}

Register your middleware in app/Http/Kernel.php by adding to the $routeMiddleware variable

protected $routeMiddleware = [
    // ...
    'webhook' => \App\Http\Middleware\VerifyWebhook::class,
];

When used, this middleware will verify any webhooks your app recieves by calculating a digital signature. Simply register this middleware on your route or in your controller

Route::post('webhook/shopify/order-created', function(\Illuminate\Http\Request $request) {
    // Handle order created
})->middleware('webhook');

Uninstall Hook

If a user decides to uninstall your app -- unlikely, because you build awesome apps! -- you can register a webhook with Shopify to be hit when that occurs.

We'll register an uninstall webhook by creating a RegisterUninstallShopifyWebhook which we will fire off in the handleProviderCallback method of the LoginShopifyController.

Make the job

php artisan make:job RegisterUninstallShopifyWebhook

Implement the job

/**
 * @var string
 */
public $domain;

/**
 * @var string
 */
public $token;

/**
 * @var \App\Store
 */
public $store;

/**
 * Create a new job instance.
 *
 * @return void
 */
public function __construct($domain, $token, \App\Store $store)
{
    $this->domain = $domain;
    $this->token = $token;
    $this->store = $store;
}

/**
 * Execute the job.
 *
 * @return void
 */
public function handle()
{

    $shopify = \Shopify::retrieve($this->domain, $this->token);

    // Get the current uninstall webhooks
    $uninstallWebhook = array_get($shopify->get('webhooks', [
        'topic' => 'app/uninstalled',
        'limit' => 250,
        'fields' => 'id,address'
    ]), 'webhooks', []);

    // Check if the uninstall webhook has already been registered
    if(collect($uninstallWebhook)->isEmpty()) {
        $shopify->create('webhooks', [
            'webhook' => [
                'topic' => 'app/uninstalled',
                'address' => env('APP_URL') . "webhook/shopify/uninstall",
                'format' => 'json'
            ]
        ]);
    }

}

Update our handleProviderCallback method in LoginShopifyController to fire the RegisterUninstallShopifyWebhook job

// ... 

// Setup uninstall webhook
dispatch(new \App\Jobs\RegisterUninstallShopifyWebhook($store->domain, $shopifyUser->token, $store));


return redirect('/home');

Finally, we just need to implement our uninstall route

Route::post('webhook/shopify/uninstall', function(\Illuminate\Http\Request $request) {
    // Handle app uninstall
})->middleware('webhook');

GDPR

Your app can implement webhooks to handle GDPR. These webhooks are built into Shopify and are easy to incorporate into your app

Head to the Shopify Partner Dashboard and open up your app settings. Find the Mandatory Webhooks section of the settings and set them to your app.
Screen-Shot-2018-08-09-at-12.37.54-am

Now all that's left is to implement the routes

Route::post('webhook/shopify/gdpr/customer-redact', function(\Illuminate\Http\Request $request) {
    // Remove customer data
})->middleware('auth.webhook');

Route::post('webhook/shopify/gdpr/shop-redact', function(\Illuminate\Http\Request $request) {
    // Remove shop data
})->middleware('webhook');

Getting the "Shopify" look

Shopify provides the Polaris UI kit for building Shopify apps. This helps ensure Shopify store owners get a consistent experience, even when using 3rd party apps.

Learn more about Polaris

Deployment

You can deploy your new Shopify app like any other Laravel app but you can do it in just minutes with Codemason.

$ mason services:create my-shopify-app/web -p 80:80 --env-file .env

Creating service on Codemason...... done

    NAME     IMAGE                                   COMMAND     PORTS
    web      registry.mason.ci/benm/my-shopify-app               80:80

Learn more about deploying with Codemason


About Codemason

We help Shopify app developers deploy their apps. Spending less time on deploying means spending more time focusing on what counts - building great apps!

Working on a Shopify app? Try Codemason!

Ben Magg
Author

Ben Magg

Founder of Codemason

Comments