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?
- Prepare your Shopify app
- Prepare your Laravel app
- Migrations and Models
- Authentication and Registration
- Billing
- Webhooks
- Getting the "Shopify" look
- Deployment
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.
Once created, you will immediately see you app API keys. We will need those later
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
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 (make:auth
and migrate
)
First run make:auth
php artisan make:auth
Then update the create_users_table
migration so the email column is not unique.
Next run the migrate
command to create the tables in our database
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->nickname,
'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 register view inside resources/views/auth/register.blade.php
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>
Congratulations! You can now sign into your app with your Shopify account 😎
SECURITY UPDATE: Thanks to Margus for noticing that Shopify's OAuth implementation is not a typical implementation like Facebook, Google etc. Shopify allows multiple users to use the same email address without requiring email verification.
To ensure someone can't access data from other stores, we make use of the$shopifyUser->nickname
field as the user's name, so the store domain will always be checked as part of authentication and registration.
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.
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.
Deployment
Codemason users can deploy our new Shopify app in one-click using the "Deploy to Codemason" button
Of course, you can also deploy your new Shopify app like any other Laravel app in a few minutes on 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!