Skip to main content

Command Palette

Search for a command to run...

How I Built a Shopify Custom App with Laravel: A Step-by-Step Guide

Updated
6 min read
How I Built a Shopify Custom App with Laravel: A Step-by-Step Guide

How I Built a Shopify Custom App with Laravel (Step-by-Step)

TL;DR: A complete walkthrough of building a Shopify custom app with Laravel — from Partner account setup to OAuth, webhooks, order syncing, and deployment. Includes real cost breakdown. If you're searching for a practical Shopify + Laravel tutorial, this is it.


Why I Chose This Stack

I had a client who needed a custom Shopify app — not a public app store listing, but a private app for their specific store. They needed order syncing, inventory management, and a custom admin dashboard. I'm a Laravel developer, so the choice was easy.

Laravel gives you queues, migrations, Eloquent, and a clean HTTP client. Shopify gives you well-documented REST and GraphQL APIs. Together, they move fast.


Step 1: Shopify Partner Account Setup

Before writing any code, you need a Shopify Partner account.

  1. Go to partners.shopify.com and sign up (free)
  2. Navigate to AppsCreate app
  3. Choose Custom app (not public — this is for a single merchant)
  4. Note down your API Key and API Secret
  5. Set your App URL to your Laravel app's URL (must be HTTPS)
  6. Set your Redirect URL to https://yourdomain.com/auth/callback
  7. Configure your scopes — I used: read_products,write_products,read_orders,write_orders,read_inventory

Creating a Development Store

In your Partner dashboard:

  1. Go to StoresAdd storeDevelopment store
  2. Name it, pick a purpose, and create it
  3. This gives you a free test store with all Shopify features

Step 2: Laravel Project Setup

laravel new shopify-sync-app
cd shopify-sync-app

Install dependencies:

composer require guzzlehttp/guzzle
composer require predis/predis # For Redis queues

Configure your .env:

APP_URL=https://yourdomain.com

SHOPIFY_API_KEY=your_api_key_here
SHOPIFY_API_SECRET=your_api_secret_here
SHOPIFY_SCOPES=read_products,write_products,read_orders,read_inventory

QUEUE_CONNECTION=redis
CACHE_DRIVER=redis
SESSION_DRIVER=redis

Database Migration

// database/migrations/create_shops_table.php
Schema::create('shops', function (Blueprint $table) {
    $table->id();
    $table->string('domain')->unique();
    $table->string('access_token')->encrypted();
    $table->string('scopes')->nullable();
    $table->timestamp('installed_at')->nullable();
    $table->timestamps();
});

// database/migrations/create_synced_orders_table.php
Schema::create('synced_orders', function (Blueprint $table) {
    $table->id();
    $table->foreignId('shop_id')->constrained()->onDelete('cascade');
    $table->string('shopify_order_id')->unique();
    $table->string('order_number');
    $table->string('email')->nullable();
    $table->decimal('total_price', 10, 2);
    $table->string('financial_status');
    $table->json('raw_data');
    $table->timestamp('shopify_created_at');
    $table->timestamps();
});

Run migrations:

php artisan migrate

Step 3: OAuth Implementation

Shopify's OAuth flow has two parts: the install redirect and the callback.

Routes

// routes/web.php
Route::get('/', function () {
    return view('welcome');
});

Route::get('/auth/install', [ShopifyController::class, 'install']);
Route::get('/auth/callback', [ShopifyController::class, 'callback']);

Controller

// app/Http/Controllers/ShopifyController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use App\Models\Shop;

class ShopifyController extends Controller
{
    public function install(Request $request)
    {
        $shopDomain = $request->query('shop');

        if (!$shopDomain || !preg_match('/^[a-zA-Z0-9\-]+\.myshopify\.com$/', $shopDomain)) {
            abort(400, 'Invalid shop domain');
        }

        $redirectUri = config('app.url') . '/auth/callback';
        $scopes = config('services.shopify.scopes');

        $authUrl = "https://{$shopDomain}/admin/oauth/authorize?" . http_build_query([
            'client_id' => config('services.shopify.key'),
            'scope' => $scopes,
            'redirect_uri' => $redirectUri,
            'state' => csrf_token(),
        ]);

        return redirect($authUrl);
    }

    public function callback(Request $request)
    {
        // Step 1: Verify HMAC
        $this->verifyHmac($request);

        // Step 2: Validate shop domain
        $shopDomain = $request->query('shop');
        if (!preg_match('/^[a-zA-Z0-9\-]+\.myshopify\.com$/', $shopDomain)) {
            abort(400, 'Invalid shop domain');
        }

        // Step 3: Exchange code for access token
        $response = Http::post("https://{$shopDomain}/admin/oauth/access_token", [
            'client_id' => config('services.shopify.key'),
            'client_secret' => config('services.shopify.secret'),
            'code' => $request->query('code'),
        ]);

        if ($response->failed()) {
            abort(500, 'Failed to get access token');
        }

        $accessToken = $response->json('access_token');

        // Step 4: Store the shop
        $shop = Shop::updateOrCreate(
            ['domain' => $shopDomain],
            [
                'access_token' => $accessToken,
                'scopes' => $response->json('scope'),
                'installed_at' => now(),
            ]
        );

        // Step 5: Register webhooks
        $this->registerWebhooks($shop);

        // Step 6: Perform initial sync
        $this->syncOrders($shop);

        return redirect("https://{$shopDomain}/admin/apps");
    }

    private function verifyHmac(Request $request): void
    {
        $hmac = $request->query('hmac');
        $params = collect($request->query())->except('hmac')->sortKeys();

        $message = $params->map(fn($value, $key) => "{$key}={$value}")->join('&');
        $computed = hash_hmac('sha256', $message, config('services.shopify.secret'));

        if (!hash_equals($hmac, $computed)) {
            abort(403, 'HMAC verification failed');
        }
    }

    private function registerWebhooks(Shop $shop): void
    {
        $webhooks = [
            ['topic' => 'orders/create', 'address' => config('app.url') . '/webhooks/orders'],
            ['topic' => 'orders/updated', 'address' => config('app.url') . '/webhooks/orders'],
            ['topic' => 'app/uninstalled', 'address' => config('app.url') . '/webhooks/uninstall'],
        ];

        foreach ($webhooks as $webhook) {
            Http::withHeaders(['X-Shopify-Access-Token' => $shop->access_token])
                ->post("https://{$shop->domain}/admin/api/2024-01/webhooks.json", [
                    'webhook' => array_merge($webhook, ['format' => 'json']),
                ]);
        }
    }
}

Step 4: Order Syncing

Initial Sync (Paginated)

// app/Services/OrderSyncService.php
namespace App\Services;

use Illuminate\Support\Facades\Http;
use App\Models\Shop;
use App\Models\SyncedOrder;

class OrderSyncService
{
    public function syncAll(Shop $shop): int
    {
        $count = 0;
        $url = "https://{$shop->domain}/admin/api/2024-01/orders.json?status=any&limit=250";

        while ($url) {
            $response = Http::withHeaders(['X-Shopify-Access-Token' => $shop->access_token])
                ->get($url);

            $orders = $response->json('orders', []);

            foreach ($orders as $order) {
                SyncedOrder::updateOrCreate(
                    [
                        'shop_id' => $shop->id,
                        'shopify_order_id' => (string) $order['id'],
                    ],
                    [
                        'order_number' => $order['order_number'],
                        'email' => $order['email'] ?? $order['contact_email'],
                        'total_price' => $order['total_price'],
                        'financial_status' => $order['financial_status'],
                        'raw_data' => $order,
                        'shopify_created_at' => $order['created_at'],
                    ]
                );
                $count++;
            }

            // Follow pagination link header
            $url = $this->getNextPageUrl($response->header('Link'));
        }

        return $count;
    }

    private function getNextPageUrl(?string $linkHeader): ?string
    {
        if (!$linkHeader) return null;

        preg_match('/<([^>]+)>; rel="next"/', $linkHeader, $matches);
        return $matches[1] ?? null;
    }
}

Webhook Handler for Real-Time Updates

// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Jobs\ProcessOrderWebhook;
use App\Jobs\HandleAppUninstall;

class WebhookController extends Controller
{
    public function orders(Request $request)
    {
        $this->verifyWebhook($request);

        $shopDomain = $request->header('x-shopify-shop-domain');

        ProcessOrderWebhook::dispatch($shopDomain, $request->all());

        return response()->json(['received' => true], 200);
    }

    public function uninstall(Request $request)
    {
        $this->verifyWebhook($request);

        HandleAppUninstall::dispatch(
            $request->header('x-shopify-shop-domain')
        );

        return response()->json(['received' => true], 200);
    }

    private function verifyWebhook(Request $request): void
    {
        $hmacHeader = $request->header('x-shopify-hmac-sha256');
        $computed = base64_encode(
            hash_hmac('sha256', $request->getContent(), config('services.shopify.secret'), true)
        );

        if (!hash_equals($hmacHeader, $computed)) {
            abort(401);
        }
    }
}
// app/Jobs/ProcessOrderWebhook.php
namespace App\Jobs;

use App\Models\Shop;
use App\Models\SyncedOrder;

class ProcessOrderWebhook implements \Illuminate\Contracts\Queue\ShouldQueue
{
    public int $tries = 3;
    public int $backoff = 30;

    public function __construct(
        public string $shopDomain,
        public array $orderData
    ) {}

    public function handle(): void
    {
        $shop = Shop::where('domain', $this->shopDomain)->first();

        if (!$shop) return;

        SyncedOrder::updateOrCreate(
            ['shop_id' => $shop->id, 'shopify_order_id' => (string) $this->orderData['id']],
            [
                'order_number' => $this->orderData['order_number'],
                'email' => $this->orderData['email'] ?? null,
                'total_price' => $this->orderData['total_price'],
                'financial_status' => $this->orderStatus['financial_status'],
                'raw_data' => $this->orderData,
                'shopify_created_at' => $this->orderData['created_at'],
            ]
        );
    }
}

Step 5: Deployment

Server Requirements

  • PHP 8.2+
  • Redis (for queues and caching)
  • MySQL 8.0+
  • SSL certificate (Let's Encrypt is free)
  • Supervisor for queue workers

Quick Deploy with a VPS

I used a $6/month DigitalOcean droplet. Here's the essentials:

# Clone and install
git clone your-repo
composer install --optimize-autoloader --no-dev
php artisan config:cache
php artisan route:cache
php artisan migrate --force

# Start queue worker
php artisan queue:work redis --tries=3 --daemon

Supervisor config for the queue worker:

[program:shopify-queue]
command=php /var/www/shopify-app/artisan queue:work redis --sleep=3 --tries=3
numprocs=2
autostart=true
autorestart=true

Cost Breakdown

Here's what it actually cost to build and run this app:

ItemCost
Shopify Partner AccountFree
Development StoreFree
DigitalOcean Droplet (1GB)$6/month
Domain Name$12/year
SSL (Let's Encrypt)Free
Redis (on same server)Included
Laravel (open source)Free
Total Setup$0
Monthly Running~$7/month

The only real cost is the server. Everything else is free or open source.


Lessons Learned

  1. Always verify HMAC. Both on OAuth callback and on webhooks. Skip this and you're open to attacks.
  2. Return 200 immediately from webhook endpoints. Queue everything. Shopify retries if you're slow.
  3. Paginate carefully. Shopify returns max 250 orders per page. Use the Link header for pagination.
  4. Handle the uninstall webhook. Clean up tokens and data when merchants uninstall.
  5. Use Redis for queues, not the database driver. Database queues don't scale and cause locking issues.
  6. Test with a development store first. Never test against a live merchant store.

Building a Shopify app with Laravel is straightforward once you understand the OAuth flow and webhook patterns. The initial setup takes a day, and the rest is building features on top of that foundation.

More from this blog

M

Masud Rana

7 posts

I am highly skilled full-stack software engineer specializing in Laravel, PHP, JS, React, Vue, Inertia.js, and Shopify, with strong experience in Filament Frontend and prompt engineering.