<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Masud Rana]]></title><description><![CDATA[I am highly skilled full-stack software engineer specializing in Laravel, PHP, JS, React, Vue, Inertia.js, and Shopify, with strong experience in Filament Front]]></description><link>https://notes.masud.pro</link><generator>RSS for Node</generator><lastBuildDate>Sat, 11 Apr 2026 16:12:09 GMT</lastBuildDate><atom:link href="https://notes.masud.pro/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a Shopify App Backend with Laravel: OAuth, Webhooks, and Multi-Tenancy]]></title><description><![CDATA[Building a Shopify App Backend with Laravel: OAuth, Webhooks, and Multi-Tenancy
TL;DR: This guide walks through building a production-ready Shopify app backend with Laravel — covering OAuth via App Bridge, webhook handling with queues, multi-tenant d...]]></description><link>https://notes.masud.pro/building-shopify-app-backend-laravel-oauth-webhooks</link><guid isPermaLink="true">https://notes.masud.pro/building-shopify-app-backend-laravel-oauth-webhooks</guid><dc:creator><![CDATA[Masud Rana]]></dc:creator><pubDate>Sat, 11 Apr 2026 11:05:53 GMT</pubDate><enclosure url="https://files.catbox.moe/pbrdw8.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-building-a-shopify-app-backend-with-laravel-oauth-webhooks-and-multi-tenancy">Building a Shopify App Backend with Laravel: OAuth, Webhooks, and Multi-Tenancy</h1>
<p><strong>TL;DR:</strong> This guide walks through building a production-ready Shopify app backend with Laravel — covering OAuth via App Bridge, webhook handling with queues, multi-tenant data isolation, and deployment tips. If you're a Laravel developer building your first Shopify app, this is the architecture guide I wish I had.</p>
<hr />
<h2 id="heading-why-laravel-for-shopify-apps">Why Laravel for Shopify Apps?</h2>
<p>Laravel and Shopify are a surprisingly good pairing. Shopify's REST/GraphQL APIs play well with Laravel's HTTP client, the queue system handles webhooks beautifully, and Eloquent makes multi-tenant data isolation straightforward. Plus, if you're already a Laravel developer, you get to ship faster than learning a new framework.</p>
<p>Let's break this down into the four pillars you need to nail.</p>
<hr />
<h2 id="heading-1-oauth-flow-with-shopify-app-bridge">1. OAuth Flow with Shopify App Bridge</h2>
<p>Shopify apps use OAuth 2.0 for installation. When a merchant clicks "Add app" from the Shopify App Store, Shopify redirects them to your app with a <code>code</code> parameter. Your job is to exchange that code for a permanent access token.</p>
<h3 id="heading-the-installation-flow">The Installation Flow</h3>
<p>Here's what happens step by step:</p>
<ol>
<li>Merchant installs your app from Shopify</li>
<li>Shopify redirects to your app with <code>shop</code>, <code>hmac</code>, <code>code</code>, and <code>timestamp</code></li>
<li>You verify the HMAC, exchange the <code>code</code> for an access token</li>
<li>Store the token and shop domain — you'll need both forever</li>
</ol>
<h3 id="heading-setting-up-the-oauth-controller">Setting Up the OAuth Controller</h3>
<pre><code class="lang-php"><span class="hljs-comment">// routes/web.php</span>
Route::get(<span class="hljs-string">'/auth'</span>, [ShopifyAuthController::class, <span class="hljs-string">'install'</span>]);
Route::get(<span class="hljs-string">'/auth/callback'</span>, [ShopifyAuthController::class, <span class="hljs-string">'callback'</span>]);
</code></pre>
<pre><code class="lang-php"><span class="hljs-comment">// app/Http/Controllers/ShopifyAuthController.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Controllers</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Request</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Support</span>\<span class="hljs-title">Facades</span>\<span class="hljs-title">Http</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">Shop</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ShopifyAuthController</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">Controller</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">install</span>(<span class="hljs-params">Request $request</span>)
    </span>{
        $shop = $request-&gt;query(<span class="hljs-string">'shop'</span>);
        $scopes = <span class="hljs-string">'read_products,write_products,read_orders'</span>;
        $redirectUri = config(<span class="hljs-string">'app.url'</span>) . <span class="hljs-string">'/auth/callback'</span>;
        $nonce = bin2hex(random_bytes(<span class="hljs-number">16</span>));

        session([<span class="hljs-string">'oauth_nonce'</span> =&gt; $nonce]);

        $installUrl = <span class="hljs-string">"https://<span class="hljs-subst">{$shop}</span>/admin/oauth/authorize?"</span> . http_build_query([
            <span class="hljs-string">'client_id'</span> =&gt; config(<span class="hljs-string">'services.shopify.api_key'</span>),
            <span class="hljs-string">'scope'</span> =&gt; $scopes,
            <span class="hljs-string">'redirect_uri'</span> =&gt; $redirectUri,
            <span class="hljs-string">'state'</span> =&gt; $nonce,
        ]);

        <span class="hljs-keyword">return</span> redirect($installUrl);
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">callback</span>(<span class="hljs-params">Request $request</span>)
    </span>{
        <span class="hljs-comment">// Verify HMAC first — never skip this</span>
        $hmac = $request-&gt;query(<span class="hljs-string">'hmac'</span>);
        $params = $request-&gt;except(<span class="hljs-string">'hmac'</span>);
        ksort($params);

        $computedHmac = hash_hmac(
            <span class="hljs-string">'sha256'</span>,
            http_build_query($params),
            config(<span class="hljs-string">'services.shopify.api_secret'</span>)
        );

        <span class="hljs-keyword">if</span> (!hash_equals($hmac, $computedHmac)) {
            abort(<span class="hljs-number">403</span>, <span class="hljs-string">'Invalid HMAC'</span>);
        }

        <span class="hljs-comment">// Exchange code for access token</span>
        $response = Http::post(<span class="hljs-string">"https://<span class="hljs-subst">{$request-&gt;shop}</span>/admin/oauth/access_token"</span>, [
            <span class="hljs-string">'client_id'</span> =&gt; config(<span class="hljs-string">'services.shopify.api_key'</span>),
            <span class="hljs-string">'client_secret'</span> =&gt; config(<span class="hljs-string">'services.shopify.api_secret'</span>),
            <span class="hljs-string">'code'</span> =&gt; $request-&gt;code,
        ]);

        $accessToken = $response-&gt;json(<span class="hljs-string">'access_token'</span>);

        Shop::updateOrCreate(
            [<span class="hljs-string">'domain'</span> =&gt; $request-&gt;shop],
            [<span class="hljs-string">'access_token'</span> =&gt; $accessToken, <span class="hljs-string">'installed_at'</span> =&gt; now()]
        );

        <span class="hljs-keyword">return</span> redirect(<span class="hljs-string">"https://<span class="hljs-subst">{$request-&gt;shop}</span>/admin/apps/"</span> . config(<span class="hljs-string">'services.shopify.api_key'</span>));
    }
}
</code></pre>
<h3 id="heading-app-bridge-integration">App Bridge Integration</h3>
<p>For embedded apps, Shopify uses App Bridge to render your app inside the Shopify admin. On the frontend, you'll need:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// resources/js/app.js</span>
<span class="hljs-keyword">import</span> { createApp } <span class="hljs-keyword">from</span> <span class="hljs-string">'@shopify/app-bridge'</span>;
<span class="hljs-keyword">import</span> { Redirect } <span class="hljs-keyword">from</span> <span class="hljs-string">'@shopify/app-bridge/actions'</span>;

<span class="hljs-keyword">const</span> app = createApp({
    <span class="hljs-attr">apiKey</span>: process.env.SHOPIFY_API_KEY,
    <span class="hljs-attr">shopOrigin</span>: <span class="hljs-keyword">new</span> URL(<span class="hljs-built_in">window</span>.location).searchParams.get(<span class="hljs-string">'shop'</span>),
    <span class="hljs-attr">host</span>: <span class="hljs-keyword">new</span> URL(<span class="hljs-built_in">window</span>.location).searchParams.get(<span class="hljs-string">'host'</span>),
});
</code></pre>
<p>The key insight: always verify HMAC server-side. Never trust client-side data.</p>
<hr />
<h2 id="heading-2-webhooks-with-laravel-queues-and-retries">2. Webhooks with Laravel Queues and Retries</h2>
<p>Shopify sends webhooks for events like order creation, product updates, and app uninstallation. You need to handle these reliably — missing an order webhook means unhappy merchants.</p>
<h3 id="heading-registering-webhooks">Registering Webhooks</h3>
<pre><code class="lang-php"><span class="hljs-comment">// After OAuth, register your webhooks</span>
Http::withHeaders([<span class="hljs-string">'X-Shopify-Access-Token'</span> =&gt; $shop-&gt;access_token])
    -&gt;post(<span class="hljs-string">"https://<span class="hljs-subst">{$shop-&gt;domain}</span>/admin/api/2024-01/webhooks.json"</span>, [
        <span class="hljs-string">'webhook'</span> =&gt; [
            <span class="hljs-string">'topic'</span> =&gt; <span class="hljs-string">'orders/create'</span>,
            <span class="hljs-string">'address'</span> =&gt; config(<span class="hljs-string">'app.url'</span>) . <span class="hljs-string">'/api/webhooks/orders'</span>,
            <span class="hljs-string">'format'</span> =&gt; <span class="hljs-string">'json'</span>,
        ],
    ]);
</code></pre>
<h3 id="heading-the-webhook-controller">The Webhook Controller</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Http/Controllers/WebhookController.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Controllers</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Request</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Jobs</span>\<span class="hljs-title">ProcessShopifyOrder</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Jobs</span>\<span class="hljs-title">HandleAppUninstall</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">WebhookController</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">Controller</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handleOrders</span>(<span class="hljs-params">Request $request</span>)
    </span>{
        <span class="hljs-keyword">$this</span>-&gt;verifyWebhook($request);

        $shop = Shop::where(<span class="hljs-string">'domain'</span>, $request-&gt;header(<span class="hljs-string">'x-shopify-shop-domain'</span>))
            -&gt;firstOrFail();

        ProcessShopifyOrder::dispatch($shop, $request-&gt;all());

        <span class="hljs-keyword">return</span> response()-&gt;json([<span class="hljs-string">'status'</span> =&gt; <span class="hljs-string">'queued'</span>], <span class="hljs-number">200</span>);
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handleUninstall</span>(<span class="hljs-params">Request $request</span>)
    </span>{
        <span class="hljs-keyword">$this</span>-&gt;verifyWebhook($request);

        $shop = Shop::where(<span class="hljs-string">'domain'</span>, $request-&gt;header(<span class="hljs-string">'x-shopify-shop-domain'</span>))
            -&gt;firstOrFail();

        HandleAppUninstall::dispatch($shop);

        <span class="hljs-keyword">return</span> response()-&gt;json([<span class="hljs-string">'status'</span> =&gt; <span class="hljs-string">'ok'</span>], <span class="hljs-number">200</span>);
    }

    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">verifyWebhook</span>(<span class="hljs-params">Request $request</span>): <span class="hljs-title">void</span>
    </span>{
        $hmac = $request-&gt;header(<span class="hljs-string">'x-shopify-hmac-sha256'</span>);
        $computed = base64_encode(
            hash_hmac(<span class="hljs-string">'sha256'</span>, $request-&gt;getContent(), config(<span class="hljs-string">'services.shopify.api_secret'</span>), <span class="hljs-literal">true</span>)
        );

        <span class="hljs-keyword">if</span> (!hash_equals($hmac, $computed)) {
            abort(<span class="hljs-number">401</span>, <span class="hljs-string">'Webhook verification failed'</span>);
        }
    }
}
</code></pre>
<h3 id="heading-the-queue-job-with-retries">The Queue Job with Retries</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Jobs/ProcessShopifyOrder.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Jobs</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Bus</span>\<span class="hljs-title">Queueable</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Contracts</span>\<span class="hljs-title">Queue</span>\<span class="hljs-title">ShouldQueue</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Queue</span>\<span class="hljs-title">InteractsWithQueue</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Queue</span>\<span class="hljs-title">SerializesModels</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Throwable</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ProcessShopifyOrder</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">ShouldQueue</span>
</span>{
    <span class="hljs-keyword">use</span> <span class="hljs-title">InteractsWithQueue</span>, <span class="hljs-title">Queueable</span>, <span class="hljs-title">SerializesModels</span>;

    <span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> $tries = <span class="hljs-number">5</span>;
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> $backoff = <span class="hljs-number">60</span>; <span class="hljs-comment">// seconds between retries</span>
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">bool</span> $failOnTimeout = <span class="hljs-literal">true</span>;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span>(<span class="hljs-params">
        <span class="hljs-keyword">public</span> Shop $shop,
        <span class="hljs-keyword">public</span> <span class="hljs-keyword">array</span> $orderData
    </span>) </span>{}

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handle</span>(<span class="hljs-params"></span>): <span class="hljs-title">void</span>
    </span>{
        <span class="hljs-comment">// Your order processing logic here</span>
        <span class="hljs-comment">// Sync to your database, trigger emails, update inventory, etc.</span>
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">failed</span>(<span class="hljs-params"><span class="hljs-built_in">Throwable</span> $exception</span>): <span class="hljs-title">void</span>
    </span>{
        logger()-&gt;error(<span class="hljs-string">'Order webhook failed after 5 retries'</span>, [
            <span class="hljs-string">'shop'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;shop-&gt;domain,
            <span class="hljs-string">'order_id'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;orderData[<span class="hljs-string">'id'</span>] ?? <span class="hljs-literal">null</span>,
            <span class="hljs-string">'error'</span> =&gt; $exception-&gt;getMessage(),
        ]);
    }
}
</code></pre>
<p><strong>Pro tip:</strong> Always return a <code>200</code> response immediately and let queues handle the work. Shopify retries webhooks if it doesn't get a 200 within 5 seconds.</p>
<hr />
<h2 id="heading-3-multi-tenant-data-isolation">3. Multi-Tenant Data Isolation</h2>
<p>Every Shopify merchant is a separate tenant. You need to ensure Merchant A never sees Merchant B's data. There are two main approaches:</p>
<h3 id="heading-approach-a-global-scope-recommended-for-most-apps">Approach A: Global Scope (Recommended for Most Apps)</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Models/Traits/BelongsToShop.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">Traits</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Database</span>\<span class="hljs-title">Eloquent</span>\<span class="hljs-title">Builder</span>;

<span class="hljs-keyword">trait</span> BelongsToShop
{
    <span class="hljs-keyword">protected</span> <span class="hljs-built_in">static</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">bootBelongsToShop</span>(<span class="hljs-params"></span>): <span class="hljs-title">void</span>
    </span>{
        <span class="hljs-built_in">static</span>::addGlobalScope(<span class="hljs-string">'shop'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">Builder $builder</span>) </span>{
            <span class="hljs-keyword">if</span> ($shopId = request()-&gt;attributes-&gt;get(<span class="hljs-string">'shop_id'</span>)) {
                $builder-&gt;where(<span class="hljs-string">'shop_id'</span>, $shopId);
            }
        });

        <span class="hljs-built_in">static</span>::creating(<span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">$model</span>) </span>{
            <span class="hljs-keyword">if</span> ($shopId = request()-&gt;attributes-&gt;get(<span class="hljs-string">'shop_id'</span>)) {
                $model-&gt;shop_id = $shopId;
            }
        });
    }
}
</code></pre>
<pre><code class="lang-php"><span class="hljs-comment">// Middleware that resolves the shop from session/token</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Middleware</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Closure</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">Shop</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ResolveShop</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handle</span>(<span class="hljs-params">$request, <span class="hljs-built_in">Closure</span> $next</span>)
    </span>{
        $shopDomain = $request-&gt;header(<span class="hljs-string">'x-shopify-shop-domain'</span>)
            ?? session(<span class="hljs-string">'shop_domain'</span>);

        $shop = Shop::where(<span class="hljs-string">'domain'</span>, $shopDomain)-&gt;firstOrFail();
        $request-&gt;attributes-&gt;set(<span class="hljs-string">'shop_id'</span>, $shop-&gt;id);
        $request-&gt;attributes-&gt;set(<span class="hljs-string">'shop'</span>, $shop);

        <span class="hljs-keyword">return</span> $next($request);
    }
}
</code></pre>
<p>Apply the trait to any model that belongs to a shop:</p>
<pre><code class="lang-php"><span class="hljs-comment">// app/Models/Product.php</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Product</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">Model</span>
</span>{
    <span class="hljs-keyword">use</span> <span class="hljs-title">BelongsToShop</span>;
}
</code></pre>
<h3 id="heading-approach-b-separate-databases-for-enterprise-scale">Approach B: Separate Databases (For Enterprise Scale)</h3>
<p>If you're dealing with heavy per-shop data isolation requirements, use separate databases:</p>
<pre><code class="lang-php"><span class="hljs-comment">// config/database.php</span>
<span class="hljs-string">'shop_connection'</span> =&gt; [
    <span class="hljs-string">'driver'</span> =&gt; <span class="hljs-string">'mysql'</span>,
    <span class="hljs-string">'database'</span> =&gt; env(<span class="hljs-string">'SHOP_DB_PREFIX'</span>, <span class="hljs-string">'shop_'</span>) . $shopId,
    <span class="hljs-comment">// ...</span>
],
</code></pre>
<p>This adds complexity (migrations across N databases) but gives you true isolation. For 95% of Shopify apps, the global scope approach is sufficient.</p>
<hr />
<h2 id="heading-4-deployment-tips">4. Deployment Tips</h2>
<h3 id="heading-environment-essentials">Environment Essentials</h3>
<pre><code class="lang-env">SHOPIFY_API_KEY=your_key
SHOPIFY_API_SECRET=your_secret
SHOPIFY_SCOPES=read_products,write_products,read_orders
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
</code></pre>
<h3 id="heading-queue-workers">Queue Workers</h3>
<p>Run queue workers with Supervisor:</p>
<pre><code class="lang-ini"><span class="hljs-comment"># /etc/supervisor/conf.d/shopify-worker.conf</span>
<span class="hljs-section">[program:shopify-worker]</span>
<span class="hljs-attr">process_name</span>=%(program_name)s_%(process_num)<span class="hljs-number">02</span>d
<span class="hljs-attr">command</span>=php /path/to/artisan queue:work redis --sleep=<span class="hljs-number">3</span> --tries=<span class="hljs-number">5</span> --max-time=<span class="hljs-number">3600</span>
<span class="hljs-attr">autostart</span>=<span class="hljs-literal">true</span>
<span class="hljs-attr">autorestart</span>=<span class="hljs-literal">true</span>
<span class="hljs-attr">stopasgroup</span>=<span class="hljs-literal">true</span>
<span class="hljs-attr">killasgroup</span>=<span class="hljs-literal">true</span>
<span class="hljs-attr">numprocs</span>=<span class="hljs-number">3</span>
<span class="hljs-attr">redirect_stderr</span>=<span class="hljs-literal">true</span>
<span class="hljs-attr">stdout_logfile</span>=/var/log/shopify-worker.log
</code></pre>
<h3 id="heading-shopify-specific-deployment-notes">Shopify-Specific Deployment Notes</h3>
<ul>
<li><strong>HTTPS is mandatory.</strong> Shopify won't send webhooks to HTTP endpoints.</li>
<li><strong>Set your app URL in Shopify Partners dashboard</strong> to match your production URL exactly.</li>
<li><strong>Use Laravel Horizon</strong> if you want a dashboard for monitoring webhook processing.</li>
<li><strong>Rate limits matter.</strong> Shopify's REST API allows 2 requests per second per shop. Use Laravel's <code>RateLimiter</code> or throttle your API calls.</li>
<li><strong>Always handle the <code>app/uninstalled</code> webhook</strong> to revoke tokens and clean up data.</li>
</ul>
<hr />
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Building a Shopify app with Laravel comes down to four things:</p>
<ol>
<li><strong>OAuth</strong> — verify HMAC, exchange codes, store tokens</li>
<li><strong>Webhooks</strong> — verify, queue, retry, log failures</li>
<li><strong>Multi-tenancy</strong> — global scopes or separate databases</li>
<li><strong>Reliability</strong> — queues, supervisors, monitoring</li>
</ol>
<p>The architecture is straightforward. The details are where the pain lives — HMAC verification edge cases, webhook retry storms, and Shopify API rate limits. Handle those well and you'll have an app that merchants can trust.</p>
<p>If you're building your first Shopify app, start with the OAuth flow. Get that working, and everything else builds on top of it.</p>
]]></content:encoded></item><item><title><![CDATA[Adding AI-Powered Product Descriptions to Your Shopify App]]></title><description><![CDATA[Adding AI-Powered Product Descriptions to Your Shopify App
TL;DR: Learn how to integrate OpenAI into your Shopify Laravel app to auto-generate product descriptions, build a Polaris-based admin UI, handle bulk generation with queues, and implement usa...]]></description><link>https://notes.masud.pro/ai-powered-product-descriptions-shopify-app</link><guid isPermaLink="true">https://notes.masud.pro/ai-powered-product-descriptions-shopify-app</guid><dc:creator><![CDATA[Masud Rana]]></dc:creator><pubDate>Sat, 11 Apr 2026 11:05:49 GMT</pubDate><enclosure url="https://files.catbox.moe/vziagv.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-adding-ai-powered-product-descriptions-to-your-shopify-app">Adding AI-Powered Product Descriptions to Your Shopify App</h1>
<p><strong>TL;DR:</strong> Learn how to integrate OpenAI into your Shopify Laravel app to auto-generate product descriptions, build a Polaris-based admin UI, handle bulk generation with queues, and implement usage-based billing. Everything you need to ship an AI-powered Shopify app feature.</p>
<hr />
<h2 id="heading-the-opportunity">The Opportunity</h2>
<p>Writing product descriptions is tedious. Merchants with hundreds of SKUs often have terrible descriptions — or none at all. An AI-powered description generator is one of the highest-value features you can add to a Shopify app, and merchants are willing to pay for it.</p>
<p>Here's how to build it end to end.</p>
<hr />
<h2 id="heading-1-openai-integration-in-laravel">1. OpenAI Integration in Laravel</h2>
<p>Start by installing the OpenAI PHP client:</p>
<pre><code class="lang-bash">composer require openai-php/laravel
</code></pre>
<p>Publish the config and add your API key:</p>
<pre><code class="lang-env">OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o-mini
</code></pre>
<h3 id="heading-the-description-generator-service">The Description Generator Service</h3>
<p>Create a clean service class that handles prompt engineering and API calls:</p>
<pre><code class="lang-php"><span class="hljs-comment">// app/Services/DescriptionGenerator.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Services</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">OpenAI</span>\<span class="hljs-title">Laravel</span>\<span class="hljs-title">Facades</span>\<span class="hljs-title">OpenAI</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">Shop</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DescriptionGenerator</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">generate</span>(<span class="hljs-params">Shop $shop, <span class="hljs-keyword">array</span> $product, <span class="hljs-keyword">string</span> $tone = <span class="hljs-string">'professional'</span></span>): <span class="hljs-title">string</span>
    </span>{
        $prompt = <span class="hljs-keyword">$this</span>-&gt;buildPrompt($product, $tone);

        $response = OpenAI::chat()-&gt;create([
            <span class="hljs-string">'model'</span> =&gt; config(<span class="hljs-string">'openai.model'</span>, <span class="hljs-string">'gpt-4o-mini'</span>),
            <span class="hljs-string">'messages'</span> =&gt; [
                [<span class="hljs-string">'role'</span> =&gt; <span class="hljs-string">'system'</span>, <span class="hljs-string">'content'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;systemPrompt()],
                [<span class="hljs-string">'role'</span> =&gt; <span class="hljs-string">'user'</span>, <span class="hljs-string">'content'</span> =&gt; $prompt],
            ],
            <span class="hljs-string">'max_tokens'</span> =&gt; <span class="hljs-number">500</span>,
            <span class="hljs-string">'temperature'</span> =&gt; <span class="hljs-number">0.7</span>,
        ]);

        <span class="hljs-keyword">return</span> $response-&gt;choices[<span class="hljs-number">0</span>]-&gt;message-&gt;content;
    }

    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">systemPrompt</span>(<span class="hljs-params"></span>): <span class="hljs-title">string</span>
    </span>{
        <span class="hljs-keyword">return</span> <span class="hljs-string">"You are an expert e-commerce copywriter. Write compelling, SEO-friendly "</span>
             . <span class="hljs-string">"product descriptions. Use appropriate HTML formatting (h3, p, ul, li). "</span>
             . <span class="hljs-string">"Never mention you are an AI. Focus on benefits, not just features."</span>;
    }

    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">buildPrompt</span>(<span class="hljs-params"><span class="hljs-keyword">array</span> $product, <span class="hljs-keyword">string</span> $tone</span>): <span class="hljs-title">string</span>
    </span>{
        <span class="hljs-keyword">return</span> <span class="hljs-string">"Generate a product description for:\n\n"</span>
             . <span class="hljs-string">"Title: <span class="hljs-subst">{$product['title']}</span>\n"</span>
             . <span class="hljs-string">"Price: <span class="hljs-subst">{$product['price']}</span>\n"</span>
             . <span class="hljs-string">"Category: "</span> . ($product[<span class="hljs-string">'product_type'</span>] ?? <span class="hljs-string">'General'</span>) . <span class="hljs-string">"\n"</span>
             . <span class="hljs-string">"Tags: "</span> . implode(<span class="hljs-string">', '</span>, $product[<span class="hljs-string">'tags'</span>] ?? []) . <span class="hljs-string">"\n"</span>
             . <span class="hljs-string">"Current description: "</span> . ($product[<span class="hljs-string">'body_html'</span>] ?? <span class="hljs-string">'None'</span>) . <span class="hljs-string">"\n\n"</span>
             . <span class="hljs-string">"Tone: <span class="hljs-subst">{$tone}</span>\n"</span>
             . <span class="hljs-string">"Length: 2-3 paragraphs with bullet points for key features."</span>;
    }
}
</code></pre>
<h3 id="heading-handling-token-limits-and-errors">Handling Token Limits and Errors</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Services/DescriptionGenerator.php (extended)</span>
<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">generateWithRetry</span>(<span class="hljs-params">Shop $shop, <span class="hljs-keyword">array</span> $product, <span class="hljs-keyword">string</span> $tone = <span class="hljs-string">'professional'</span>, <span class="hljs-keyword">int</span> $maxRetries = <span class="hljs-number">2</span></span>): ?<span class="hljs-title">string</span>
</span>{
    <span class="hljs-keyword">for</span> ($attempt = <span class="hljs-number">0</span>; $attempt &lt;= $maxRetries; $attempt++) {
        <span class="hljs-keyword">try</span> {
            $description = <span class="hljs-keyword">$this</span>-&gt;generate($shop, $product, $tone);

            <span class="hljs-comment">// Track usage for billing</span>
            $shop-&gt;increment(<span class="hljs-string">'ai_tokens_used'</span>, strlen($description));

            <span class="hljs-keyword">return</span> $description;
        } <span class="hljs-keyword">catch</span> (\OpenAI\Exceptions\<span class="hljs-built_in">ErrorException</span> $e) {
            <span class="hljs-keyword">if</span> (str_contains($e-&gt;getMessage(), <span class="hljs-string">'rate_limit'</span>) &amp;&amp; $attempt &lt; $maxRetries) {
                sleep(pow(<span class="hljs-number">2</span>, $attempt)); <span class="hljs-comment">// Exponential backoff</span>
                <span class="hljs-keyword">continue</span>;
            }
            logger()-&gt;error(<span class="hljs-string">'OpenAI generation failed'</span>, [
                <span class="hljs-string">'shop'</span> =&gt; $shop-&gt;domain,
                <span class="hljs-string">'product'</span> =&gt; $product[<span class="hljs-string">'id'</span>] ?? <span class="hljs-literal">null</span>,
                <span class="hljs-string">'error'</span> =&gt; $e-&gt;getMessage(),
            ]);
            <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
        }
    }
    <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
}
</code></pre>
<hr />
<h2 id="heading-2-react-embed-in-shopify-admin-with-polaris">2. React Embed in Shopify Admin with Polaris</h2>
<p>Your app lives inside Shopify's admin as an embedded app. You need a React frontend using Shopify's Polaris component library.</p>
<h3 id="heading-setting-up-the-frontend">Setting Up the Frontend</h3>
<pre><code class="lang-bash">npm create vite@latest frontend -- --template react
<span class="hljs-built_in">cd</span> frontend
npm install @shopify/polaris @shopify/app-bridge-react
</code></pre>
<h3 id="heading-the-description-generator-ui">The Description Generator UI</h3>
<pre><code class="lang-jsx"><span class="hljs-comment">// frontend/src/components/DescriptionGenerator.jsx</span>
<span class="hljs-keyword">import</span> { useState } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;
<span class="hljs-keyword">import</span> {
    Card,
    TextField,
    Select,
    Button,
    TextContainer,
    SkeletonBodyText,
    Banner,
    Stack,
    Layout,
} <span class="hljs-keyword">from</span> <span class="hljs-string">'@shopify/polaris'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">DescriptionGenerator</span>(<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">const</span> [product, setProduct] = useState(<span class="hljs-literal">null</span>);
    <span class="hljs-keyword">const</span> [tone, setTone] = useState(<span class="hljs-string">'professional'</span>);
    <span class="hljs-keyword">const</span> [generated, setGenerated] = useState(<span class="hljs-string">''</span>);
    <span class="hljs-keyword">const</span> [loading, setLoading] = useState(<span class="hljs-literal">false</span>);

    <span class="hljs-keyword">const</span> tones = [
        { <span class="hljs-attr">label</span>: <span class="hljs-string">'Professional'</span>, <span class="hljs-attr">value</span>: <span class="hljs-string">'professional'</span> },
        { <span class="hljs-attr">label</span>: <span class="hljs-string">'Casual'</span>, <span class="hljs-attr">value</span>: <span class="hljs-string">'casual'</span> },
        { <span class="hljs-attr">label</span>: <span class="hljs-string">'Luxury'</span>, <span class="hljs-attr">value</span>: <span class="hljs-string">'luxury'</span> },
        { <span class="hljs-attr">label</span>: <span class="hljs-string">'Technical'</span>, <span class="hljs-attr">value</span>: <span class="hljs-string">'technical'</span> },
        { <span class="hljs-attr">label</span>: <span class="hljs-string">'Playful'</span>, <span class="hljs-attr">value</span>: <span class="hljs-string">'playful'</span> },
    ];

    <span class="hljs-keyword">const</span> handleGenerate = <span class="hljs-keyword">async</span> () =&gt; {
        setLoading(<span class="hljs-literal">true</span>);
        <span class="hljs-keyword">try</span> {
            <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">'/api/generate-description'</span>, {
                <span class="hljs-attr">method</span>: <span class="hljs-string">'POST'</span>,
                <span class="hljs-attr">headers</span>: {
                    <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span>,
                    <span class="hljs-string">'X-CSRF-TOKEN'</span>: <span class="hljs-built_in">document</span>.querySelector(<span class="hljs-string">'meta[name="csrf-token"]'</span>)?.content,
                },
                <span class="hljs-attr">body</span>: <span class="hljs-built_in">JSON</span>.stringify({ <span class="hljs-attr">product_id</span>: product, tone }),
            });
            <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> response.json();
            setGenerated(data.description);
        } <span class="hljs-keyword">catch</span> (err) {
            <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Generation failed:'</span>, err);
        } <span class="hljs-keyword">finally</span> {
            setLoading(<span class="hljs-literal">false</span>);
        }
    };

    <span class="hljs-keyword">return</span> (
        <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Layout</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">Layout.Section</span>&gt;</span>
                <span class="hljs-tag">&lt;<span class="hljs-name">Card</span> <span class="hljs-attr">sectioned</span>&gt;</span>
                    <span class="hljs-tag">&lt;<span class="hljs-name">TextContainer</span>&gt;</span>
                        <span class="hljs-tag">&lt;<span class="hljs-name">TextField</span>
                            <span class="hljs-attr">label</span>=<span class="hljs-string">"Product ID or Title"</span>
                            <span class="hljs-attr">value</span>=<span class="hljs-string">{product}</span>
                            <span class="hljs-attr">onChange</span>=<span class="hljs-string">{setProduct}</span>
                        /&gt;</span>
                        <span class="hljs-tag">&lt;<span class="hljs-name">Select</span>
                            <span class="hljs-attr">label</span>=<span class="hljs-string">"Tone"</span>
                            <span class="hljs-attr">options</span>=<span class="hljs-string">{tones}</span>
                            <span class="hljs-attr">value</span>=<span class="hljs-string">{tone}</span>
                            <span class="hljs-attr">onChange</span>=<span class="hljs-string">{setTone}</span>
                        /&gt;</span>
                        <span class="hljs-tag">&lt;<span class="hljs-name">Button</span> <span class="hljs-attr">primary</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{handleGenerate}</span> <span class="hljs-attr">loading</span>=<span class="hljs-string">{loading}</span>&gt;</span>
                            Generate Description
                        <span class="hljs-tag">&lt;/<span class="hljs-name">Button</span>&gt;</span>
                    <span class="hljs-tag">&lt;/<span class="hljs-name">TextContainer</span>&gt;</span>
                <span class="hljs-tag">&lt;/<span class="hljs-name">Card</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">Layout.Section</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">Layout.Section</span>&gt;</span>
                {loading ? (
                    <span class="hljs-tag">&lt;<span class="hljs-name">Card</span> <span class="hljs-attr">sectioned</span>&gt;</span>
                        <span class="hljs-tag">&lt;<span class="hljs-name">SkeletonBodyText</span> <span class="hljs-attr">lines</span>=<span class="hljs-string">{6}</span> /&gt;</span>
                    <span class="hljs-tag">&lt;/<span class="hljs-name">Card</span>&gt;</span>
                ) : generated ? (
                    <span class="hljs-tag">&lt;<span class="hljs-name">Card</span> <span class="hljs-attr">sectioned</span> <span class="hljs-attr">title</span>=<span class="hljs-string">"Generated Description"</span>&gt;</span>
                        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">dangerouslySetInnerHTML</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">__html:</span> <span class="hljs-attr">generated</span> }} /&gt;</span>
                        <span class="hljs-tag">&lt;<span class="hljs-name">Stack</span> <span class="hljs-attr">distribution</span>=<span class="hljs-string">"trailing"</span>&gt;</span>
                            <span class="hljs-tag">&lt;<span class="hljs-name">Button</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{()</span> =&gt;</span> navigator.clipboard.writeText(generated)}&gt;
                                Copy
                            <span class="hljs-tag">&lt;/<span class="hljs-name">Button</span>&gt;</span>
                            <span class="hljs-tag">&lt;<span class="hljs-name">Button</span> <span class="hljs-attr">primary</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{handleApply}</span>&gt;</span>
                                Apply to Product
                            <span class="hljs-tag">&lt;/<span class="hljs-name">Button</span>&gt;</span>
                        <span class="hljs-tag">&lt;/<span class="hljs-name">Stack</span>&gt;</span>
                    <span class="hljs-tag">&lt;/<span class="hljs-name">Card</span>&gt;</span>
                ) : null}
            <span class="hljs-tag">&lt;/<span class="hljs-name">Layout.Section</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">Layout</span>&gt;</span></span>
    );
}
</code></pre>
<h3 id="heading-laravel-api-endpoint">Laravel API Endpoint</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Http/Controllers/Api/DescriptionController.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Controllers</span>\<span class="hljs-title">Api</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Controllers</span>\<span class="hljs-title">Controller</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Services</span>\<span class="hljs-title">DescriptionGenerator</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">Product</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">DescriptionController</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">Controller</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span>(<span class="hljs-params">
        <span class="hljs-keyword">private</span> DescriptionGenerator $generator
    </span>) </span>{}

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">generate</span>(<span class="hljs-params">Request $request</span>)
    </span>{
        $request-&gt;validate([
            <span class="hljs-string">'product_id'</span> =&gt; <span class="hljs-string">'required|string'</span>,
            <span class="hljs-string">'tone'</span> =&gt; <span class="hljs-string">'sometimes|in:professional,casual,luxury,technical,playful'</span>,
        ]);

        $shop = $request-&gt;attributes-&gt;get(<span class="hljs-string">'shop'</span>);
        $product = Product::where(<span class="hljs-string">'shop_id'</span>, $shop-&gt;id)
            -&gt;where(<span class="hljs-string">'shopify_product_id'</span>, $request-&gt;product_id)
            -&gt;firstOrFail();

        $description = <span class="hljs-keyword">$this</span>-&gt;generator-&gt;generateWithRetry(
            $shop,
            $product-&gt;toShopifyArray(),
            $request-&gt;tone ?? <span class="hljs-string">'professional'</span>
        );

        <span class="hljs-keyword">if</span> (!$description) {
            <span class="hljs-keyword">return</span> response()-&gt;json([<span class="hljs-string">'error'</span> =&gt; <span class="hljs-string">'Generation failed'</span>], <span class="hljs-number">500</span>);
        }

        <span class="hljs-keyword">return</span> response()-&gt;json([<span class="hljs-string">'description'</span> =&gt; $description]);
    }
}
</code></pre>
<hr />
<h2 id="heading-3-bulk-generation-with-queues">3. Bulk Generation with Queues</h2>
<p>Merchants with 500+ products won't generate descriptions one at a time. Bulk generation is essential.</p>
<h3 id="heading-the-bulk-job">The Bulk Job</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Jobs/BulkGenerateDescriptions.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Jobs</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Bus</span>\{<span class="hljs-title">Batchable</span>, <span class="hljs-title">Queueable</span>};
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Contracts</span>\<span class="hljs-title">Queue</span>\<span class="hljs-title">ShouldQueue</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Queue</span>\{<span class="hljs-title">InteractsWithQueue</span>, <span class="hljs-title">SerializesModels</span>};
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Support</span>\<span class="hljs-title">Facades</span>\<span class="hljs-title">Bus</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Services</span>\<span class="hljs-title">DescriptionGenerator</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">Product</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">BulkJob</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">GenerateSingleDescription</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">ShouldQueue</span>
</span>{
    <span class="hljs-keyword">use</span> <span class="hljs-title">Batchable</span>, <span class="hljs-title">InteractsWithQueue</span>, <span class="hljs-title">Queueable</span>, <span class="hljs-title">SerializesModels</span>;

    <span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> $tries = <span class="hljs-number">2</span>;
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> $backoff = <span class="hljs-number">10</span>;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span>(<span class="hljs-params">
        <span class="hljs-keyword">public</span> BulkJob $bulkJob,
        <span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> $productId
    </span>) </span>{}

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handle</span>(<span class="hljs-params">DescriptionGenerator $generator</span>): <span class="hljs-title">void</span>
    </span>{
        <span class="hljs-keyword">if</span> (<span class="hljs-keyword">$this</span>-&gt;batch()?-&gt;cancelled()) {
            <span class="hljs-keyword">return</span>;
        }

        $product = Product::find(<span class="hljs-keyword">$this</span>-&gt;productId);
        $description = $generator-&gt;generateWithRetry(
            $product-&gt;shop,
            $product-&gt;toShopifyArray(),
            <span class="hljs-keyword">$this</span>-&gt;bulkJob-&gt;tone
        );

        <span class="hljs-keyword">if</span> ($description) {
            $product-&gt;update([<span class="hljs-string">'generated_description'</span> =&gt; $description]);
        }

        <span class="hljs-keyword">$this</span>-&gt;bulkJob-&gt;increment(<span class="hljs-string">'processed_count'</span>);
    }
}
</code></pre>
<h3 id="heading-dispatching-the-batch">Dispatching the Batch</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Http/Controllers/Api/BulkController.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Controllers</span>\<span class="hljs-title">Api</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Support</span>\<span class="hljs-title">Facades</span>\<span class="hljs-title">Bus</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Jobs</span>\<span class="hljs-title">GenerateSingleDescription</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">BulkJob</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">BulkController</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">Controller</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">start</span>(<span class="hljs-params">Request $request</span>)
    </span>{
        $shop = $request-&gt;attributes-&gt;get(<span class="hljs-string">'shop'</span>);

        $products = $shop-&gt;products()
            -&gt;whereNull(<span class="hljs-string">'generated_description'</span>)
            -&gt;pluck(<span class="hljs-string">'id'</span>);

        $bulkJob = BulkJob::create([
            <span class="hljs-string">'shop_id'</span> =&gt; $shop-&gt;id,
            <span class="hljs-string">'total_count'</span> =&gt; $products-&gt;count(),
            <span class="hljs-string">'processed_count'</span> =&gt; <span class="hljs-number">0</span>,
            <span class="hljs-string">'tone'</span> =&gt; $request-&gt;tone ?? <span class="hljs-string">'professional'</span>,
            <span class="hljs-string">'status'</span> =&gt; <span class="hljs-string">'processing'</span>,
        ]);

        $jobs = $products-&gt;map(
            <span class="hljs-function"><span class="hljs-keyword">fn</span>(<span class="hljs-params">$id</span>) =&gt; <span class="hljs-title">new</span> <span class="hljs-title">GenerateSingleDescription</span>(<span class="hljs-params">$bulkJob, $id</span>)
        )</span>;

        $batch = Bus::batch($jobs)
            -&gt;name(<span class="hljs-string">"bulk-descriptions-<span class="hljs-subst">{$shop-&gt;id}</span>"</span>)
            -&gt;allowFailures()
            -&gt;finally(<span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) <span class="hljs-title">use</span> (<span class="hljs-params">$bulkJob</span>) </span>{
                $bulkJob-&gt;update([<span class="hljs-string">'status'</span> =&gt; <span class="hljs-string">'completed'</span>]);
            })
            -&gt;dispatch();

        $bulkJob-&gt;update([<span class="hljs-string">'batch_id'</span> =&gt; $batch-&gt;id]);

        <span class="hljs-keyword">return</span> response()-&gt;json([
            <span class="hljs-string">'bulk_job_id'</span> =&gt; $bulkJob-&gt;id,
            <span class="hljs-string">'total'</span> =&gt; $products-&gt;count(),
        ]);
    }
}
</code></pre>
<p>This uses Laravel's job batching — you get progress tracking, cancellation support, and failure handling out of the box.</p>
<hr />
<h2 id="heading-4-usage-based-billing">4. Usage-Based Billing</h2>
<p>Shopify supports usage-based billing through the Billing API. You charge merchants based on how many descriptions they generate.</p>
<h3 id="heading-setting-up-a-usage-charge">Setting Up a Usage Charge</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Services/ShopifyBilling.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Services</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Support</span>\<span class="hljs-title">Facades</span>\<span class="hljs-title">Http</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">Shop</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ShopifyBilling</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">createUsageRecord</span>(<span class="hljs-params">Shop $shop, <span class="hljs-keyword">int</span> $units, <span class="hljs-keyword">float</span> $pricePerUnit = <span class="hljs-number">0.05</span></span>): <span class="hljs-title">void</span>
    </span>{
        $mutation = &lt;&lt;&lt;<span class="hljs-string">'GraphQL'</span>
        mutation appUsageRecordCreate($id: ID!, $description: <span class="hljs-keyword">String</span>!, $price: Decimal!, $url: URL) {
            appUsageRecordCreate(input: {
                appSubscriptionLineItemId: $id
                description: $description
                price: { amount: $price, currencyCode: USD }
            }) {
                userErrors { field message }
                appUsageRecord { id }
            }
        }
        GraphQL;

        Http::withHeaders([<span class="hljs-string">'X-Shopify-Access-Token'</span> =&gt; $shop-&gt;access_token])
            -&gt;post(<span class="hljs-string">"https://<span class="hljs-subst">{$shop-&gt;domain}</span>/admin/api/2024-01/graphql.json"</span>, [
                <span class="hljs-string">'query'</span> =&gt; $mutation,
                <span class="hljs-string">'variables'</span> =&gt; [
                    <span class="hljs-string">'id'</span> =&gt; $shop-&gt;subscription_line_item_id,
                    <span class="hljs-string">'description'</span> =&gt; <span class="hljs-string">"AI Description Generation (<span class="hljs-subst">{$units}</span> descriptions)"</span>,
                    <span class="hljs-string">'price'</span> =&gt; $units * $pricePerUnit,
                ],
            ]);
    }
}
</code></pre>
<h3 id="heading-tracking-usage-locally">Tracking Usage Locally</h3>
<p>Keep a local counter so you can bill at intervals:</p>
<pre><code class="lang-php"><span class="hljs-comment">// In your GenerateSingleDescription job, after successful generation:</span>
$shop-&gt;increment(<span class="hljs-string">'descriptions_generated'</span>);
$shop-&gt;increment(<span class="hljs-string">'ai_credits_used'</span>);

<span class="hljs-comment">// Check if we should bill</span>
<span class="hljs-keyword">if</span> ($shop-&gt;ai_credits_used &gt;= $shop-&gt;billing_threshold) {
    app(ShopifyBilling::class)-&gt;createUsageRecord(
        $shop,
        $shop-&gt;ai_credits_used
    );
    $shop-&gt;update([<span class="hljs-string">'ai_credits_used'</span> =&gt; <span class="hljs-number">0</span>]);
}
</code></pre>
<h3 id="heading-pricing-model-example">Pricing Model Example</h3>
<p>A simple model that works well:</p>
<ul>
<li><strong>Free tier:</strong> 10 descriptions/month</li>
<li><strong>Pro:</strong> $9.99/month base + $0.05 per description over 100</li>
<li><strong>Enterprise:</strong> $49.99/month with 1,000 descriptions included</li>
</ul>
<hr />
<h2 id="heading-putting-it-all-together">Putting It All Together</h2>
<p>The flow looks like this:</p>
<ol>
<li>Merchant selects products in your Polaris UI</li>
<li>Frontend calls your Laravel API</li>
<li>API dispatches batch jobs to the queue</li>
<li>Each job calls OpenAI, saves the description, and tracks usage</li>
<li>Usage gets billed through Shopify's Billing API</li>
<li>Merchant sees progress and reviews generated descriptions in the UI</li>
</ol>
<p>The stack is: <strong>Laravel (backend) + React/Polaris (frontend) + Redis (queues) + OpenAI (AI) + Shopify Billing (payments).</strong></p>
<p>One last piece of advice: always show the merchant a preview before applying AI-generated content to their live store. Give them a copy button and an "Apply" button. Trust builds retention.</p>
<p>Ship it, iterate on the prompts, and watch the usage metrics. The quality of your AI output is your product's moat — invest time in prompt engineering.</p>
]]></content:encoded></item><item><title><![CDATA[How I Built a Shopify Custom App with Laravel: A Step-by-Step Guide]]></title><description><![CDATA[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 ...]]></description><link>https://notes.masud.pro/shopify-custom-app-laravel-step-by-step</link><guid isPermaLink="true">https://notes.masud.pro/shopify-custom-app-laravel-step-by-step</guid><dc:creator><![CDATA[Masud Rana]]></dc:creator><pubDate>Sat, 11 Apr 2026 11:05:46 GMT</pubDate><enclosure url="https://files.catbox.moe/g5yo1m.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-how-i-built-a-shopify-custom-app-with-laravel-step-by-step">How I Built a Shopify Custom App with Laravel (Step-by-Step)</h1>
<p><strong>TL;DR:</strong> 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.</p>
<hr />
<h2 id="heading-why-i-chose-this-stack">Why I Chose This Stack</h2>
<p>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.</p>
<p>Laravel gives you queues, migrations, Eloquent, and a clean HTTP client. Shopify gives you well-documented REST and GraphQL APIs. Together, they move fast.</p>
<hr />
<h2 id="heading-step-1-shopify-partner-account-setup">Step 1: Shopify Partner Account Setup</h2>
<p>Before writing any code, you need a Shopify Partner account.</p>
<ol>
<li>Go to <a target="_blank" href="https://partners.shopify.com">partners.shopify.com</a> and sign up (free)</li>
<li>Navigate to <strong>Apps</strong> → <strong>Create app</strong></li>
<li>Choose <strong>Custom app</strong> (not public — this is for a single merchant)</li>
<li>Note down your <strong>API Key</strong> and <strong>API Secret</strong></li>
<li>Set your <strong>App URL</strong> to your Laravel app's URL (must be HTTPS)</li>
<li>Set your <strong>Redirect URL</strong> to <code>https://yourdomain.com/auth/callback</code></li>
<li>Configure your <strong>scopes</strong> — I used: <code>read_products,write_products,read_orders,write_orders,read_inventory</code></li>
</ol>
<h3 id="heading-creating-a-development-store">Creating a Development Store</h3>
<p>In your Partner dashboard:</p>
<ol>
<li>Go to <strong>Stores</strong> → <strong>Add store</strong> → <strong>Development store</strong></li>
<li>Name it, pick a purpose, and create it</li>
<li>This gives you a free test store with all Shopify features</li>
</ol>
<hr />
<h2 id="heading-step-2-laravel-project-setup">Step 2: Laravel Project Setup</h2>
<pre><code class="lang-bash">laravel new shopify-sync-app
<span class="hljs-built_in">cd</span> shopify-sync-app
</code></pre>
<p>Install dependencies:</p>
<pre><code class="lang-bash">composer require guzzlehttp/guzzle
composer require predis/predis <span class="hljs-comment"># For Redis queues</span>
</code></pre>
<p>Configure your <code>.env</code>:</p>
<pre><code class="lang-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
</code></pre>
<h3 id="heading-database-migration">Database Migration</h3>
<pre><code class="lang-php"><span class="hljs-comment">// database/migrations/create_shops_table.php</span>
Schema::create(<span class="hljs-string">'shops'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">Blueprint $table</span>) </span>{
    $table-&gt;id();
    $table-&gt;string(<span class="hljs-string">'domain'</span>)-&gt;unique();
    $table-&gt;string(<span class="hljs-string">'access_token'</span>)-&gt;encrypted();
    $table-&gt;string(<span class="hljs-string">'scopes'</span>)-&gt;nullable();
    $table-&gt;timestamp(<span class="hljs-string">'installed_at'</span>)-&gt;nullable();
    $table-&gt;timestamps();
});

<span class="hljs-comment">// database/migrations/create_synced_orders_table.php</span>
Schema::create(<span class="hljs-string">'synced_orders'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params">Blueprint $table</span>) </span>{
    $table-&gt;id();
    $table-&gt;foreignId(<span class="hljs-string">'shop_id'</span>)-&gt;constrained()-&gt;onDelete(<span class="hljs-string">'cascade'</span>);
    $table-&gt;string(<span class="hljs-string">'shopify_order_id'</span>)-&gt;unique();
    $table-&gt;string(<span class="hljs-string">'order_number'</span>);
    $table-&gt;string(<span class="hljs-string">'email'</span>)-&gt;nullable();
    $table-&gt;decimal(<span class="hljs-string">'total_price'</span>, <span class="hljs-number">10</span>, <span class="hljs-number">2</span>);
    $table-&gt;string(<span class="hljs-string">'financial_status'</span>);
    $table-&gt;json(<span class="hljs-string">'raw_data'</span>);
    $table-&gt;timestamp(<span class="hljs-string">'shopify_created_at'</span>);
    $table-&gt;timestamps();
});
</code></pre>
<p>Run migrations:</p>
<pre><code class="lang-bash">php artisan migrate
</code></pre>
<hr />
<h2 id="heading-step-3-oauth-implementation">Step 3: OAuth Implementation</h2>
<p>Shopify's OAuth flow has two parts: the install redirect and the callback.</p>
<h3 id="heading-routes">Routes</h3>
<pre><code class="lang-php"><span class="hljs-comment">// routes/web.php</span>
Route::get(<span class="hljs-string">'/'</span>, <span class="hljs-function"><span class="hljs-keyword">function</span> (<span class="hljs-params"></span>) </span>{
    <span class="hljs-keyword">return</span> view(<span class="hljs-string">'welcome'</span>);
});

Route::get(<span class="hljs-string">'/auth/install'</span>, [ShopifyController::class, <span class="hljs-string">'install'</span>]);
Route::get(<span class="hljs-string">'/auth/callback'</span>, [ShopifyController::class, <span class="hljs-string">'callback'</span>]);
</code></pre>
<h3 id="heading-controller">Controller</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Http/Controllers/ShopifyController.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Controllers</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Request</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Support</span>\<span class="hljs-title">Facades</span>\<span class="hljs-title">Http</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">Shop</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ShopifyController</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">Controller</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">install</span>(<span class="hljs-params">Request $request</span>)
    </span>{
        $shopDomain = $request-&gt;query(<span class="hljs-string">'shop'</span>);

        <span class="hljs-keyword">if</span> (!$shopDomain || !preg_match(<span class="hljs-string">'/^[a-zA-Z0-9\-]+\.myshopify\.com$/'</span>, $shopDomain)) {
            abort(<span class="hljs-number">400</span>, <span class="hljs-string">'Invalid shop domain'</span>);
        }

        $redirectUri = config(<span class="hljs-string">'app.url'</span>) . <span class="hljs-string">'/auth/callback'</span>;
        $scopes = config(<span class="hljs-string">'services.shopify.scopes'</span>);

        $authUrl = <span class="hljs-string">"https://<span class="hljs-subst">{$shopDomain}</span>/admin/oauth/authorize?"</span> . http_build_query([
            <span class="hljs-string">'client_id'</span> =&gt; config(<span class="hljs-string">'services.shopify.key'</span>),
            <span class="hljs-string">'scope'</span> =&gt; $scopes,
            <span class="hljs-string">'redirect_uri'</span> =&gt; $redirectUri,
            <span class="hljs-string">'state'</span> =&gt; csrf_token(),
        ]);

        <span class="hljs-keyword">return</span> redirect($authUrl);
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">callback</span>(<span class="hljs-params">Request $request</span>)
    </span>{
        <span class="hljs-comment">// Step 1: Verify HMAC</span>
        <span class="hljs-keyword">$this</span>-&gt;verifyHmac($request);

        <span class="hljs-comment">// Step 2: Validate shop domain</span>
        $shopDomain = $request-&gt;query(<span class="hljs-string">'shop'</span>);
        <span class="hljs-keyword">if</span> (!preg_match(<span class="hljs-string">'/^[a-zA-Z0-9\-]+\.myshopify\.com$/'</span>, $shopDomain)) {
            abort(<span class="hljs-number">400</span>, <span class="hljs-string">'Invalid shop domain'</span>);
        }

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

        <span class="hljs-keyword">if</span> ($response-&gt;failed()) {
            abort(<span class="hljs-number">500</span>, <span class="hljs-string">'Failed to get access token'</span>);
        }

        $accessToken = $response-&gt;json(<span class="hljs-string">'access_token'</span>);

        <span class="hljs-comment">// Step 4: Store the shop</span>
        $shop = Shop::updateOrCreate(
            [<span class="hljs-string">'domain'</span> =&gt; $shopDomain],
            [
                <span class="hljs-string">'access_token'</span> =&gt; $accessToken,
                <span class="hljs-string">'scopes'</span> =&gt; $response-&gt;json(<span class="hljs-string">'scope'</span>),
                <span class="hljs-string">'installed_at'</span> =&gt; now(),
            ]
        );

        <span class="hljs-comment">// Step 5: Register webhooks</span>
        <span class="hljs-keyword">$this</span>-&gt;registerWebhooks($shop);

        <span class="hljs-comment">// Step 6: Perform initial sync</span>
        <span class="hljs-keyword">$this</span>-&gt;syncOrders($shop);

        <span class="hljs-keyword">return</span> redirect(<span class="hljs-string">"https://<span class="hljs-subst">{$shopDomain}</span>/admin/apps"</span>);
    }

    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">verifyHmac</span>(<span class="hljs-params">Request $request</span>): <span class="hljs-title">void</span>
    </span>{
        $hmac = $request-&gt;query(<span class="hljs-string">'hmac'</span>);
        $params = collect($request-&gt;query())-&gt;except(<span class="hljs-string">'hmac'</span>)-&gt;sortKeys();

        $message = $params-&gt;map(<span class="hljs-function"><span class="hljs-keyword">fn</span>(<span class="hljs-params">$value, $key</span>) =&gt; "</span>{$key}={$value}<span class="hljs-string">")-&gt;join('&amp;');
        <span class="hljs-subst">$computed</span> = hash_hmac('sha256', <span class="hljs-subst">$message</span>, config('services.shopify.secret'));

        if (!hash_equals(<span class="hljs-subst">$hmac</span>, <span class="hljs-subst">$computed</span>)) {
            abort(403, 'HMAC verification failed');
        }
    }

    private function registerWebhooks(Shop <span class="hljs-subst">$shop</span>): void
    {
        <span class="hljs-subst">$webhooks</span> = [
            ['topic' =&gt; 'orders/create', 'address' =&gt; config('app.url') . '/webhooks/orders'],
            ['topic' =&gt; 'orders/updated', 'address' =&gt; config('app.url') . '/webhooks/orders'],
            ['topic' =&gt; 'app/uninstalled', 'address' =&gt; config('app.url') . '/webhooks/uninstall'],
        ];

        foreach (<span class="hljs-subst">$webhooks</span> as <span class="hljs-subst">$webhook</span>) {
            Http::withHeaders(['X-Shopify-Access-Token' =&gt; <span class="hljs-subst">$shop</span>-&gt;access_token])
                -&gt;post("</span>https:<span class="hljs-comment">//{$shop-&gt;domain}/admin/api/2024-01/webhooks.json", [</span>
                    <span class="hljs-string">'webhook'</span> =&gt; array_merge($webhook, [<span class="hljs-string">'format'</span> =&gt; <span class="hljs-string">'json'</span>]),
                ]);
        }
    }
}
</code></pre>
<hr />
<h2 id="heading-step-4-order-syncing">Step 4: Order Syncing</h2>
<h3 id="heading-initial-sync-paginated">Initial Sync (Paginated)</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Services/OrderSyncService.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Services</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Support</span>\<span class="hljs-title">Facades</span>\<span class="hljs-title">Http</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">Shop</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">SyncedOrder</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderSyncService</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">syncAll</span>(<span class="hljs-params">Shop $shop</span>): <span class="hljs-title">int</span>
    </span>{
        $count = <span class="hljs-number">0</span>;
        $url = <span class="hljs-string">"https://<span class="hljs-subst">{$shop-&gt;domain}</span>/admin/api/2024-01/orders.json?status=any&amp;limit=250"</span>;

        <span class="hljs-keyword">while</span> ($url) {
            $response = Http::withHeaders([<span class="hljs-string">'X-Shopify-Access-Token'</span> =&gt; $shop-&gt;access_token])
                -&gt;get($url);

            $orders = $response-&gt;json(<span class="hljs-string">'orders'</span>, []);

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

            <span class="hljs-comment">// Follow pagination link header</span>
            $url = <span class="hljs-keyword">$this</span>-&gt;getNextPageUrl($response-&gt;header(<span class="hljs-string">'Link'</span>));
        }

        <span class="hljs-keyword">return</span> $count;
    }

    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getNextPageUrl</span>(<span class="hljs-params">?<span class="hljs-keyword">string</span> $linkHeader</span>): ?<span class="hljs-title">string</span>
    </span>{
        <span class="hljs-keyword">if</span> (!$linkHeader) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;

        preg_match(<span class="hljs-string">'/&lt;([^&gt;]+)&gt;; rel="next"/'</span>, $linkHeader, $matches);
        <span class="hljs-keyword">return</span> $matches[<span class="hljs-number">1</span>] ?? <span class="hljs-literal">null</span>;
    }
}
</code></pre>
<h3 id="heading-webhook-handler-for-real-time-updates">Webhook Handler for Real-Time Updates</h3>
<pre><code class="lang-php"><span class="hljs-comment">// app/Http/Controllers/WebhookController.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Controllers</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">Illuminate</span>\<span class="hljs-title">Http</span>\<span class="hljs-title">Request</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Jobs</span>\<span class="hljs-title">ProcessOrderWebhook</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Jobs</span>\<span class="hljs-title">HandleAppUninstall</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">WebhookController</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">Controller</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">orders</span>(<span class="hljs-params">Request $request</span>)
    </span>{
        <span class="hljs-keyword">$this</span>-&gt;verifyWebhook($request);

        $shopDomain = $request-&gt;header(<span class="hljs-string">'x-shopify-shop-domain'</span>);

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

        <span class="hljs-keyword">return</span> response()-&gt;json([<span class="hljs-string">'received'</span> =&gt; <span class="hljs-literal">true</span>], <span class="hljs-number">200</span>);
    }

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">uninstall</span>(<span class="hljs-params">Request $request</span>)
    </span>{
        <span class="hljs-keyword">$this</span>-&gt;verifyWebhook($request);

        HandleAppUninstall::dispatch(
            $request-&gt;header(<span class="hljs-string">'x-shopify-shop-domain'</span>)
        );

        <span class="hljs-keyword">return</span> response()-&gt;json([<span class="hljs-string">'received'</span> =&gt; <span class="hljs-literal">true</span>], <span class="hljs-number">200</span>);
    }

    <span class="hljs-keyword">private</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">verifyWebhook</span>(<span class="hljs-params">Request $request</span>): <span class="hljs-title">void</span>
    </span>{
        $hmacHeader = $request-&gt;header(<span class="hljs-string">'x-shopify-hmac-sha256'</span>);
        $computed = base64_encode(
            hash_hmac(<span class="hljs-string">'sha256'</span>, $request-&gt;getContent(), config(<span class="hljs-string">'services.shopify.secret'</span>), <span class="hljs-literal">true</span>)
        );

        <span class="hljs-keyword">if</span> (!hash_equals($hmacHeader, $computed)) {
            abort(<span class="hljs-number">401</span>);
        }
    }
}
</code></pre>
<pre><code class="lang-php"><span class="hljs-comment">// app/Jobs/ProcessOrderWebhook.php</span>
<span class="hljs-keyword">namespace</span> <span class="hljs-title">App</span>\<span class="hljs-title">Jobs</span>;

<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">Shop</span>;
<span class="hljs-keyword">use</span> <span class="hljs-title">App</span>\<span class="hljs-title">Models</span>\<span class="hljs-title">SyncedOrder</span>;

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ProcessOrderWebhook</span> <span class="hljs-keyword">implements</span> \<span class="hljs-title">Illuminate</span>\<span class="hljs-title">Contracts</span>\<span class="hljs-title">Queue</span>\<span class="hljs-title">ShouldQueue</span>
</span>{
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> $tries = <span class="hljs-number">3</span>;
    <span class="hljs-keyword">public</span> <span class="hljs-keyword">int</span> $backoff = <span class="hljs-number">30</span>;

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span>(<span class="hljs-params">
        <span class="hljs-keyword">public</span> <span class="hljs-keyword">string</span> $shopDomain,
        <span class="hljs-keyword">public</span> <span class="hljs-keyword">array</span> $orderData
    </span>) </span>{}

    <span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handle</span>(<span class="hljs-params"></span>): <span class="hljs-title">void</span>
    </span>{
        $shop = Shop::where(<span class="hljs-string">'domain'</span>, <span class="hljs-keyword">$this</span>-&gt;shopDomain)-&gt;first();

        <span class="hljs-keyword">if</span> (!$shop) <span class="hljs-keyword">return</span>;

        SyncedOrder::updateOrCreate(
            [<span class="hljs-string">'shop_id'</span> =&gt; $shop-&gt;id, <span class="hljs-string">'shopify_order_id'</span> =&gt; (<span class="hljs-keyword">string</span>) <span class="hljs-keyword">$this</span>-&gt;orderData[<span class="hljs-string">'id'</span>]],
            [
                <span class="hljs-string">'order_number'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;orderData[<span class="hljs-string">'order_number'</span>],
                <span class="hljs-string">'email'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;orderData[<span class="hljs-string">'email'</span>] ?? <span class="hljs-literal">null</span>,
                <span class="hljs-string">'total_price'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;orderData[<span class="hljs-string">'total_price'</span>],
                <span class="hljs-string">'financial_status'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;orderStatus[<span class="hljs-string">'financial_status'</span>],
                <span class="hljs-string">'raw_data'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;orderData,
                <span class="hljs-string">'shopify_created_at'</span> =&gt; <span class="hljs-keyword">$this</span>-&gt;orderData[<span class="hljs-string">'created_at'</span>],
            ]
        );
    }
}
</code></pre>
<hr />
<h2 id="heading-step-5-deployment">Step 5: Deployment</h2>
<h3 id="heading-server-requirements">Server Requirements</h3>
<ul>
<li>PHP 8.2+</li>
<li>Redis (for queues and caching)</li>
<li>MySQL 8.0+</li>
<li>SSL certificate (Let's Encrypt is free)</li>
<li>Supervisor for queue workers</li>
</ul>
<h3 id="heading-quick-deploy-with-a-vps">Quick Deploy with a VPS</h3>
<p>I used a $6/month DigitalOcean droplet. Here's the essentials:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Clone and install</span>
git <span class="hljs-built_in">clone</span> your-repo
composer install --optimize-autoloader --no-dev
php artisan config:cache
php artisan route:cache
php artisan migrate --force

<span class="hljs-comment"># Start queue worker</span>
php artisan queue:work redis --tries=3 --daemon
</code></pre>
<p>Supervisor config for the queue worker:</p>
<pre><code class="lang-ini"><span class="hljs-section">[program:shopify-queue]</span>
<span class="hljs-attr">command</span>=php /var/www/shopify-app/artisan queue:work redis --sleep=<span class="hljs-number">3</span> --tries=<span class="hljs-number">3</span>
<span class="hljs-attr">numprocs</span>=<span class="hljs-number">2</span>
<span class="hljs-attr">autostart</span>=<span class="hljs-literal">true</span>
<span class="hljs-attr">autorestart</span>=<span class="hljs-literal">true</span>
</code></pre>
<hr />
<h2 id="heading-cost-breakdown">Cost Breakdown</h2>
<p>Here's what it actually cost to build and run this app:</p>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Item</td><td>Cost</td></tr>
</thead>
<tbody>
<tr>
<td>Shopify Partner Account</td><td>Free</td></tr>
<tr>
<td>Development Store</td><td>Free</td></tr>
<tr>
<td>DigitalOcean Droplet (1GB)</td><td>$6/month</td></tr>
<tr>
<td>Domain Name</td><td>$12/year</td></tr>
<tr>
<td>SSL (Let's Encrypt)</td><td>Free</td></tr>
<tr>
<td>Redis (on same server)</td><td>Included</td></tr>
<tr>
<td>Laravel (open source)</td><td>Free</td></tr>
<tr>
<td><strong>Total Setup</strong></td><td><strong>$0</strong></td></tr>
<tr>
<td><strong>Monthly Running</strong></td><td><strong>~$7/month</strong></td></tr>
</tbody>
</table>
</div><p>The only real cost is the server. Everything else is free or open source.</p>
<hr />
<h2 id="heading-lessons-learned">Lessons Learned</h2>
<ol>
<li><strong>Always verify HMAC.</strong> Both on OAuth callback and on webhooks. Skip this and you're open to attacks.</li>
<li><strong>Return 200 immediately from webhook endpoints.</strong> Queue everything. Shopify retries if you're slow.</li>
<li><strong>Paginate carefully.</strong> Shopify returns max 250 orders per page. Use the <code>Link</code> header for pagination.</li>
<li><strong>Handle the uninstall webhook.</strong> Clean up tokens and data when merchants uninstall.</li>
<li><strong>Use Redis for queues, not the database driver.</strong> Database queues don't scale and cause locking issues.</li>
<li><strong>Test with a development store first.</strong> Never test against a live merchant store.</li>
</ol>
<p>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.</p>
]]></content:encoded></item><item><title><![CDATA[I Shipped a SaaS MVP in 14 Days Using AI Coding Agents]]></title><description><![CDATA[I Shipped a SaaS MVP in 14 Days Using AI Coding Agents
TL;DR: As a solo developer, I built and launched a SaaS MVP in two weeks using AI coding agents (Claude Code, Cursor, and GitHub Copilot). Here's the day-by-day breakdown of what I built, what th...]]></description><link>https://notes.masud.pro/saas-mvp-14-days-ai-coding-agents</link><guid isPermaLink="true">https://notes.masud.pro/saas-mvp-14-days-ai-coding-agents</guid><dc:creator><![CDATA[Masud Rana]]></dc:creator><pubDate>Sat, 11 Apr 2026 11:05:43 GMT</pubDate><enclosure url="https://files.catbox.moe/a5t0gd.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-i-shipped-a-saas-mvp-in-14-days-using-ai-coding-agents">I Shipped a SaaS MVP in 14 Days Using AI Coding Agents</h1>
<p><strong>TL;DR:</strong> As a solo developer, I built and launched a SaaS MVP in two weeks using AI coding agents (Claude Code, Cursor, and GitHub Copilot). Here's the day-by-day breakdown of what I built, what the AI handled versus what I coded myself, and the real costs involved.</p>
<hr />
<h2 id="heading-the-project">The Project</h2>
<p>I wanted to build <strong>InvoiceFlow</strong> — a simple invoicing and billing dashboard for freelancers. The core features: create invoices, track payments, send payment reminders via email, and basic analytics. Nothing revolutionary, but something I could actually ship and charge for.</p>
<p>Tech stack: <strong>Next.js (frontend), Laravel (API), PostgreSQL, Stripe, and SendGrid.</strong></p>
<p>Here's how it went.</p>
<hr />
<h2 id="heading-day-1-2-planning-and-setup">Day 1-2: Planning and Setup</h2>
<p><strong>What I did:</strong></p>
<ul>
<li>Wrote a 2-page spec document</li>
<li>Designed the database schema on paper</li>
<li>Set up GitHub repository</li>
<li>Configured Next.js with TypeScript and Tailwind</li>
<li>Bootstrapped Laravel API with Sanctum for auth</li>
</ul>
<p><strong>What the AI helped with:</strong></p>
<ul>
<li>Cursor generated the initial Laravel project structure with migrations</li>
<li>Copilot suggested the TypeScript types I'd need based on my spec</li>
</ul>
<p><strong>What I coded manually:</strong></p>
<ul>
<li>The actual database schema (migrations)</li>
<li>Auth flow logic (Sanctum tokens, middleware)</li>
<li>Project architecture decisions</li>
</ul>
<p><em>Time spent: ~6 hours total</em></p>
<hr />
<h2 id="heading-day-3-4-database-and-core-models">Day 3-4: Database and Core Models</h2>
<p><strong>What I did:</strong></p>
<ul>
<li>Created all Laravel migrations (users, clients, invoices, line items, payments)</li>
<li>Set up Eloquent models with relationships</li>
<li>Created Laravel Factories for testing data</li>
<li>Wrote the base API structure</li>
</ul>
<p><strong>What the AI handled (Claude Code):</strong></p>
<pre><code class="lang-bash"><span class="hljs-comment"># I prompted: "Create a migration for invoices table with client_id, status, due_date, total_amount, notes"</span>
</code></pre>
<p>Claude generated the full migration file with proper column types, indexes, and foreign key constraints.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Then: "Create an Invoice model with relationships to Client and Payment"</span>
</code></pre>
<p>Output: A complete Eloquent model with all relationships, casts, and query scopes.</p>
<pre><code class="lang-bash"><span class="hljs-comment"># Finally: "Create factories for User, Client, Invoice, and Payment models"</span>
</code></pre>
<p>Got back 4 factory files with realistic fake data generation.</p>
<p><strong>What I coded manually:</strong></p>
<ul>
<li>Custom query scopes (e.g., <code>scopeOverdue()</code>, <code>scopePaid()</code>)</li>
<li>Model accessors and mutators for calculated fields</li>
<li>The actual business logic that would be hard for AI to infer</li>
</ul>
<p><em>Time spent: ~4 hours</em></p>
<hr />
<h2 id="heading-day-5-6-laravel-api-endpoints">Day 5-6: Laravel API Endpoints</h2>
<p><strong>What I did:</strong></p>
<ul>
<li>Built CRUD endpoints for clients, invoices, and payments</li>
<li>Implemented invoice status transitions (draft → sent → paid)</li>
<li>Added validation rules for all inputs</li>
<li>Set up API rate limiting</li>
</ul>
<p><strong>What the AI handled:</strong></p>
<p>This was where AI shined. I'd write a prompt like:</p>
<pre><code class="lang-text">Create a full REST API controller for invoices with:
- index: paginate by user with filters for status and date range
- store: validate and create with line items in a transaction
- show: return invoice with client and payments
- update: allow status changes and note updates
- destroy: soft delete only for draft invoices
</code></pre>
<p>Claude Code generated:</p>
<pre><code class="lang-php"><span class="hljs-comment">// A complete 200-line controller with:</span>
<span class="hljs-comment">// - Form request validation classes</span>
<span class="hljs-comment">// - Proper HTTP status codes</span>
<span class="hljs-comment">// - Transaction wrapping for data integrity</span>
<span class="hljs-comment">// - Resource classes for consistent JSON responses</span>
<span class="hljs-comment">// - Error handling with proper messages</span>
</code></pre>
<p>I did this for 5 different controllers. The AI saved me at least 10 hours of boilerplate.</p>
<p><strong>What I coded manually:</strong></p>
<ul>
<li>Complex business rules (e.g., "Can't change status from 'paid' to 'sent'")</li>
<li>Edge case handling</li>
<li>The actual API route definitions</li>
</ul>
<p><em>Time spent: ~5 hours</em></p>
<hr />
<h2 id="heading-day-7-8-nextjs-frontend-setup">Day 7-8: Next.js Frontend Setup</h2>
<p><strong>What I did:</strong></p>
<ul>
<li>Set up React Query for data fetching</li>
<li>Created authentication context and login form</li>
<li>Built the main dashboard layout with sidebar navigation</li>
<li>Set up routing with Next.js App Router</li>
</ul>
<p><strong>What the AI handled (Cursor):</strong></p>
<p>Cursor's inline completion was perfect here. As I typed:</p>
<pre><code class="lang-tsx">const fetchInvoices = async () =&gt; {
  const response = await fetch('/api/invoices')
  const data = await response.json()
  return data.data
}
</code></pre>
<p>Cursor suggested:</p>
<pre><code class="lang-tsx">const { data: invoices, isLoading, error, refetch } = useQuery({
  queryKey: ['invoices'],
  queryFn: fetchInvoices,
  refetchOnWindowFocus: false,
})
</code></pre>
<p>It learned my patterns and consistently suggested the right React Query hooks.</p>
<p><strong>What I coded manually:</strong></p>
<ul>
<li>The overall component architecture</li>
<li>State management decisions</li>
<li>UI/UX flow and design system</li>
</ul>
<p><em>Time spent: ~6 hours</em></p>
<hr />
<h2 id="heading-day-9-10-invoice-builder-ui">Day 9-10: Invoice Builder UI</h2>
<p><strong>What I did:</strong></p>
<ul>
<li>Built the invoice creation form</li>
<li>Implemented dynamic line item addition/removal</li>
<li>Added real-time total calculation</li>
<li>Created client selector with search</li>
</ul>
<p><strong>What the AI handled:</strong></p>
<p>I'd describe the UI component and Cursor would generate the boilerplate:</p>
<pre><code class="lang-text">Create a form component with fields: client_id (select), due_date (date picker), 
line_items (dynamic array with description, quantity, rate), notes (textarea)
</code></pre>
<p>Got back a complete form with:</p>
<ul>
<li>React Hook Form integration</li>
<li>Zod validation schema</li>
<li>Dynamic field arrays for line items</li>
<li>Auto-calculation for line item totals</li>
</ul>
<p><strong>What I coded manually:</strong></p>
<ul>
<li>The visual design and Tailwind classes</li>
<li>User experience details (e.g., focus states, error placement)</li>
<li>Real-time calculation logic (total = Σ qty × rate)</li>
</ul>
<p><em>Time spent: ~5 hours</em></p>
<hr />
<h2 id="heading-day-11-stripe-integration">Day 11: Stripe Integration</h2>
<p><strong>What I did:</strong></p>
<ul>
<li>Set up Stripe Checkout for invoice payments</li>
<li>Created Stripe webhook endpoints</li>
<li>Handled payment success/failure events</li>
<li>Updated invoice status on successful payment</li>
</ul>
<p><strong>What the AI handled:</strong></p>
<pre><code class="lang-text">Create a Stripe Checkout session controller that:
- Takes invoice_id as input
- Creates a checkout session with line items from the invoice
- Returns the checkout URL
</code></pre>
<p>Claude generated the full controller with proper error handling and Stripe configuration.</p>
<p>For webhooks:</p>
<pre><code class="lang-text">Handle Stripe checkout.session.completed webhook:
- Verify signature
- Find the invoice by metadata.invoice_id
- Mark invoice as paid
- Create a payment record
</code></pre>
<p>Got webhook verification, event parsing, and database updates in one go.</p>
<p><strong>What I coded manually:</strong></p>
<ul>
<li>Stripe account setup and API key configuration</li>
<li>Webhook signature verification (tricky to get right)</li>
<li>Error handling for edge cases (duplicate webhooks)</li>
</ul>
<p><em>Time spent: ~3 hours</em></p>
<hr />
<h2 id="heading-day-12-email-notifications">Day 12: Email Notifications</h2>
<p><strong>What I did:</strong></p>
<ul>
<li>Set up SendGrid for transactional emails</li>
<li>Created email templates for invoice delivery and payment reminders</li>
<li>Built a queued job system for email sending</li>
<li>Scheduled daily reminders for overdue invoices</li>
</ul>
<p><strong>What the AI handled:</strong></p>
<pre><code class="lang-text">Create a Laravel mailable for invoice delivery with:
- Client name and company
- Invoice number, due date, total amount
- A "Pay Now" button linking to Stripe checkout
- Clean HTML template
</code></pre>
<p>Claude generated the mailable class and Blade template. Then:</p>
<pre><code class="lang-text">Create a scheduled command that:
- Finds all overdue invoices (status = sent, due_date &lt; today)
- Sends a reminder email to each client
- Logs sent reminders to avoid duplicates
</code></pre>
<p>Got back a console command with proper scheduling and query logic.</p>
<p><strong>What I coded manually:</strong></p>
<ul>
<li>SendGrid account setup and API configuration</li>
<li>Email template design (HTML + CSS)</li>
<li>The actual scheduling in Laravel's console kernel</li>
</ul>
<p><em>Time spent: ~3 hours</em></p>
<hr />
<h2 id="heading-day-13-polish-and-bug-fixes">Day 13: Polish and Bug Fixes</h2>
<p><strong>What I did:</strong></p>
<ul>
<li>Fixed bugs discovered during testing</li>
<li>Added loading states and error messages</li>
<li>Improved mobile responsiveness</li>
<li>Added basic analytics (total invoiced, total paid, outstanding)</li>
</ul>
<p><strong>What the AI handled:</strong></p>
<p>Copilot was great for quick fixes. I'd notice a bug and start typing the fix, and it would complete it. For example, when invoice totals weren't updating after line item changes, Copilot suggested the <code>useEffect</code> dependency array fix.</p>
<p><strong>What I coded manually:</strong></p>
<ul>
<li>All debugging and bug fixes (AI can't debug what it doesn't see)</li>
<li>UX polish (animations, transitions, hover states)</li>
<li>Analytics queries and chart components</li>
</ul>
<p><em>Time spent: ~6 hours</em></p>
<hr />
<h2 id="heading-day-14-deployment-and-launch">Day 14: Deployment and Launch</h2>
<p><strong>What I did:</strong></p>
<ul>
<li>Set up Vercel for Next.js frontend</li>
<li>Deployed Laravel API to a VPS with Forge</li>
<li>Configured environment variables</li>
<li>Set up SSL and custom domain</li>
<li>Wrote documentation and onboarding guide</li>
<li>Created a landing page with pricing</li>
</ul>
<p><strong>What the AI handled:</strong></p>
<p>Not much here. Deployment is mostly infrastructure work that AI can't do. I did use Copilot for generating the <code>.env.example</code> file and some Docker configuration snippets.</p>
<p><strong>What I coded manually:</strong></p>
<ul>
<li>All deployment configuration</li>
<li>DNS and SSL setup</li>
<li>The landing page copy and design</li>
<li>Documentation</li>
</ul>
<p><em>Time spent: ~8 hours</em></p>
<hr />
<h2 id="heading-what-ai-actually-coded-vs-what-i-coded">What AI Actually Coded vs. What I Coded</h2>
<h3 id="heading-ais-contributions-60-of-code">AI's Contributions (≈60% of code):</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Category</td><td>Examples</td></tr>
</thead>
<tbody>
<tr>
<td>Boilerplate</td><td>Controllers, models, migrations, factories</td></tr>
<tr>
<td>Forms</td><td>React Hook Form setups, validation schemas</td></tr>
<tr>
<td>API Clients</td><td>Stripe, SendGrid integrations</td></tr>
<tr>
<td>Tests</td><td>Unit tests for models and controllers</td></tr>
<tr>
<td>Documentation</td><td>PHPDoc comments, README files</td></tr>
</tbody>
</table>
</div><h3 id="heading-my-contributions-40-of-code-but-100-of-decisions">My Contributions (≈40% of code, but 100% of decisions):</h3>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Category</td><td>Examples</td></tr>
</thead>
<tbody>
<tr>
<td>Architecture</td><td>Tech stack, file structure, data flow</td></tr>
<tr>
<td>Business Logic</td><td>Invoice rules, payment workflows</td></tr>
<tr>
<td>UI/UX</td><td>Design system, user flows, visual design</td></tr>
<tr>
<td>Debugging</td><td>Fixing bugs, edge cases, integration issues</td></tr>
<tr>
<td>Deployment</td><td>Infrastructure, DNS, SSL, environment setup</td></tr>
</tbody>
</table>
</div><hr />
<h2 id="heading-cost-breakdown">Cost Breakdown</h2>
<div class="hn-table">
<table>
<thead>
<tr>
<td>Item</td><td>Cost</td></tr>
</thead>
<tbody>
<tr>
<td>Claude Code (Anthropic)</td><td>$20/month (pro)</td></tr>
<tr>
<td>Cursor Pro</td><td>$20/month</td></tr>
<tr>
<td>GitHub Copilot</td><td>$10/month</td></tr>
<tr>
<td>Vercel (frontend)</td><td>Free</td></tr>
<tr>
<td>DigitalOcean (backend)</td><td>$6/month</td></tr>
<tr>
<td>Stripe (payment processing)</td><td>2.9% + $0.30 per transaction</td></tr>
<tr>
<td>SendGrid (emails)</td><td>Free tier (100/day)</td></tr>
<tr>
<td>Domain Name</td><td>$12/year</td></tr>
<tr>
<td><strong>Total AI Tools</strong></td><td><strong>$50/month</strong></td></tr>
<tr>
<td><strong>Infrastructure</strong></td><td><strong>~$7/month</strong></td></tr>
<tr>
<td><strong>Transaction Fees</strong></td><td><strong>Variable</strong></td></tr>
</tbody>
</table>
</div><p>So I spent about <strong>$57/month</strong> in recurring costs to build and run the MVP. The AI tools paid for themselves on day 3.</p>
<hr />
<h2 id="heading-what-went-well">What Went Well</h2>
<ol>
<li><strong>AI for boilerplate:</strong> Controllers, models, forms — AI generated these instantly</li>
<li><strong>Consistent code style:</strong> AI followed my patterns after learning them</li>
<li><strong>Quick iteration:</strong> I could prototype features in minutes, not hours</li>
<li><strong>Documentation:</strong> AI wrote doc comments and READMEs I wouldn't have bothered with</li>
</ol>
<hr />
<h2 id="heading-what-didnt-work">What Didn't Work</h2>
<ol>
<li><strong>Complex business logic:</strong> AI struggled with nuanced rules (e.g., "Only remind clients who haven't been reminded in 7 days")</li>
<li><strong>Debugging:</strong> AI can't debug errors it can't see. I had to debug integration issues myself</li>
<li><strong>UI polish:</strong> AI generated functional UI, but the "delight" came from my manual refinement</li>
<li><strong>Deployment:</strong> Infrastructure is still a manual job</li>
</ol>
<hr />
<h2 id="heading-the-honest-take">The Honest Take</h2>
<p>Could I have built this in 14 days without AI? No way. It would have taken me 4-6 weeks, and I probably would have burned out halfway through.</p>
<p>Did AI replace me? No. I still made every architecture decision, handled all debugging, and polished the UX. AI was a force multiplier — it handled the boring stuff so I could focus on the hard stuff.</p>
<p><strong>Verdict:</strong> AI coding agents don't replace developers. They make solo development viable. I shipped because I didn't get stuck writing boilerplate. I shipped because AI let me iterate fast enough that momentum carried me through to day 14.</p>
<p>If you're building a side project as a solo dev, get Cursor, Claude Code, or Copilot. Use them aggressively. They won't make you a 10x developer, but they'll make your 1x work go 3-5x faster. And sometimes, speed is the difference between shipping and not shipping at all.</p>
]]></content:encoded></item><item><title><![CDATA[Automating Hashnode Blog Posts with OpenClaw]]></title><description><![CDATA[Automating Hashnode Blog Posts with OpenClaw
Complete guide to automate Hashnode blog publishing using OpenClaw AI assistant.
Overview
This guide shows how to:

Set up Hashnode API credentials in OpenClaw
Use OpenClaw tools to publish blog posts
Auto...]]></description><link>https://notes.masud.pro/automating-hashnode-blog-posts-with-openclaw</link><guid isPermaLink="true">https://notes.masud.pro/automating-hashnode-blog-posts-with-openclaw</guid><dc:creator><![CDATA[Masud Rana]]></dc:creator><pubDate>Thu, 09 Apr 2026 04:48:00 GMT</pubDate><enclosure url="https://images.unsplash.com/photo-1677442136019-21780ecad995?w=1200&amp;h=630&amp;fit=crop" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-automating-hashnode-blog-posts-with-openclaw">Automating Hashnode Blog Posts with OpenClaw</h1>
<p>Complete guide to automate Hashnode blog publishing using OpenClaw AI assistant.</p>
<h2 id="heading-overview">Overview</h2>
<p>This guide shows how to:</p>
<ol>
<li>Set up Hashnode API credentials in OpenClaw</li>
<li>Use OpenClaw tools to publish blog posts</li>
<li>Automate RND reports and documentation publishing</li>
<li>Schedule automated posts</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li>OpenClaw installed and running</li>
<li>Hashnode account with blog</li>
<li>Personal Access Token from Hashnode</li>
<li>Basic knowledge of Markdown</li>
</ul>
<h2 id="heading-step-1-store-hashnode-credentials">Step 1: Store Hashnode Credentials</h2>
<p>Create a credentials file in OpenClaw workspace:</p>
<pre><code class="lang-bash"><span class="hljs-comment"># File: HASHNODE.md</span>
nano ~/.openclaw/workspace/HASHNODE.md
</code></pre>
<p>Content:</p>
<pre><code class="lang-markdown"><span class="hljs-section"># Hashnode Integration</span>

<span class="hljs-section">## Blog Details</span>
<span class="hljs-bullet">-</span> <span class="hljs-strong">**Blog URL:**</span> https://yourblog.hashnode.dev
<span class="hljs-bullet">-</span> <span class="hljs-strong">**Personal Access Token:**</span> your<span class="hljs-emphasis">_token_</span>here

<span class="hljs-section">## API Endpoint</span>
<span class="hljs-bullet">-</span> <span class="hljs-strong">**GraphQL:**</span> https://gql.hashnode.com/

<span class="hljs-section">## Publication ID</span>
<span class="hljs-bullet">-</span> <span class="hljs-strong">**ID:**</span> your<span class="hljs-emphasis">_publication_</span>id<span class="hljs-emphasis">_here</span>
</code></pre>
<p>Example:</p>
<pre><code class="lang-markdown"><span class="hljs-section"># Hashnode Integration</span>

<span class="hljs-section">## Blog Details</span>
<span class="hljs-bullet">-</span> <span class="hljs-strong">**Blog URL:**</span> https://notes.masud.pro/
<span class="hljs-bullet">-</span> <span class="hljs-strong">**Personal Access Token:**</span> 046b05d8-dcfd-42a1-aec1-4e1dde05f740

<span class="hljs-section">## API Endpoint</span>
<span class="hljs-bullet">-</span> <span class="hljs-strong">**GraphQL:**</span> https://gql.hashnode.com/

<span class="hljs-section">## Publication ID</span>
<span class="hljs-bullet">-</span> <span class="hljs-strong">**ID:**</span> 6875dce83b0919f1c1394d62
</code></pre>
<h2 id="heading-step-2-test-connection">Step 2: Test Connection</h2>
<p>Ask OpenClaw to verify the connection:</p>
<pre><code>Test my Hashnode API connection
</code></pre><p>OpenClaw will:</p>
<ol>
<li>Read credentials from HASHNODE.md</li>
<li>Query Hashnode API</li>
<li>Return your blog details</li>
</ol>
<h2 id="heading-step-3-publish-a-post-via-openclaw">Step 3: Publish a Post via OpenClaw</h2>
<h3 id="heading-simple-post">Simple Post</h3>
<pre><code>Publish a blog post <span class="hljs-keyword">with</span> title <span class="hljs-string">"My First Post"</span> and content:
# My First Post

This is my first automated post!
</code></pre><p>OpenClaw will:</p>
<ol>
<li>Read credentials</li>
<li>Prepare GraphQL mutation</li>
<li>Publish to Hashnode</li>
<li>Return the post URL</li>
</ol>
<h3 id="heading-post-with-metadata">Post with Metadata</h3>
<pre><code>Publish a blog post:
Title: <span class="hljs-string">"Building REST APIs with Laravel"</span>
<span class="hljs-attr">Content</span>:
# Building REST APIs <span class="hljs-keyword">with</span> Laravel

## Introduction
Laravel makes API development easy...

## Setup
<span class="hljs-number">1.</span> Install Laravel
<span class="hljs-number">2.</span> Configure database

## Endpoints
- GET /api/users
- POST /api/users
- PUT /api/users/{id}
- DELETE /api/users/{id}
</code></pre><h2 id="heading-step-4-batch-publish-multiple-posts">Step 4: Batch Publish Multiple Posts</h2>
<p>Create a JSON file with posts:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"posts"</span>: [
    {
      <span class="hljs-attr">"title"</span>: <span class="hljs-string">"Part 1: Getting Started"</span>,
      <span class="hljs-attr">"content"</span>: <span class="hljs-string">"# Part 1\n\nContent here..."</span>
    },
    {
      <span class="hljs-attr">"title"</span>: <span class="hljs-string">"Part 2: Advanced Features"</span>,
      <span class="hljs-attr">"content"</span>: <span class="hljs-string">"# Part 2\n\nContent here..."</span>
    }
  ]
}
</code></pre>
<p>Ask OpenClaw:</p>
<pre><code>Publish all posts <span class="hljs-keyword">from</span> posts.json to Hashnode
</code></pre><h2 id="heading-step-5-automate-rnd-reports">Step 5: Automate RND Reports</h2>
<h3 id="heading-daily-rnd-report">Daily RND Report</h3>
<p>Create a cron job in OpenClaw:</p>
<pre><code>Create a cron job that runs every Friday at <span class="hljs-number">5</span> PM to publish my weekly RND report
</code></pre><p>OpenClaw will:</p>
<ol>
<li>Create a cron job</li>
<li>Run every Friday 17:00</li>
<li>Generate report from memory files</li>
<li>Publish to Hashnode automatically</li>
</ol>
<h3 id="heading-report-template">Report Template</h3>
<p>The RND report will include:</p>
<ul>
<li>Weekly progress summary</li>
<li>Technical challenges solved</li>
<li>Code snippets and examples</li>
<li>Links to GitHub commits</li>
<li>Next week's plan</li>
</ul>
<h2 id="heading-step-6-memory-based-publishing">Step 6: Memory-Based Publishing</h2>
<p>OpenClaw can read your memory files and publish them:</p>
<pre><code>Read memory/<span class="hljs-number">2026</span><span class="hljs-number">-04</span><span class="hljs-number">-09.</span>md and publish a summary <span class="hljs-keyword">as</span> a blog post
</code></pre><p>This creates:</p>
<ul>
<li>Title based on date</li>
<li>Summary of key events</li>
<li>Technical decisions</li>
<li>Lessons learned</li>
</ul>
<h2 id="heading-step-7-integration-with-other-tools">Step 7: Integration with Other Tools</h2>
<h3 id="heading-notion-hashnode">Notion → Hashnode</h3>
<pre><code>Sync my Notion page <span class="hljs-string">"PQM Architecture"</span> to Hashnode
</code></pre><h3 id="heading-github-hashnode">GitHub → Hashnode</h3>
<pre><code>Publish my README.md <span class="hljs-keyword">from</span> repo <span class="hljs-string">"fusion-cart"</span> <span class="hljs-keyword">as</span> a blog post
</code></pre><h3 id="heading-daily-notes-weekly-digest">Daily Notes → Weekly Digest</h3>
<pre><code>Create a weekly digest <span class="hljs-keyword">from</span> memory files and publish to Hashnode
</code></pre><h2 id="heading-advanced-features">Advanced Features</h2>
<h3 id="heading-custom-post-templates">Custom Post Templates</h3>
<p>Create templates in workspace:</p>
<pre><code class="lang-markdown"><span class="hljs-section"># Template: Technical Post</span>

<span class="hljs-section">## Problem</span>
{{PROBLEM<span class="hljs-emphasis">_STATEMENT}}

## Solution
{{SOLUTION}}

## Code
```{{LANGUAGE}}
{{CODE}}</span>
</code></pre>
<h2 id="heading-results">Results</h2>
<p>{{RESULTS}}</p>
<pre><code>
Ask OpenClaw:
</code></pre><p>Use the Technical Post template to publish my solution for "Fixing Laravel Queue Issues"</p>
<pre><code>
### SEO Optimization
</code></pre><p>Publish a post about "Laravel Performance Tips" with SEO meta tags</p>
<pre><code>
OpenClaw will add:
- Optimized title
- Meta description
- Tags/keywords
- OpenGraph tags

### Scheduled Publishing
</code></pre><p>Schedule this post for next Monday 10 AM:
Title: "Weekly Tech Update"
Content: # Weekly Update...</p>
<pre><code>
## Troubleshooting

### Issue: Token Not Found

**<span class="hljs-built_in">Error</span>:** <span class="hljs-string">`No Hashnode credentials found`</span>

**Solution:**
<span class="hljs-number">1.</span> Check HASHNODE.md exists <span class="hljs-keyword">in</span> workspace
<span class="hljs-number">2.</span> Verify token is correct
<span class="hljs-number">3.</span> Check file permissions

### Issue: Publish Failed

**<span class="hljs-built_in">Error</span>:** <span class="hljs-string">`Failed to publish post`</span>

**Solution:**
<span class="hljs-number">1.</span> Check publication ID
<span class="hljs-number">2.</span> Verify markdown format
<span class="hljs-number">3.</span> Check API rate limits

### Issue: Cron Not Running

**<span class="hljs-built_in">Error</span>:** <span class="hljs-string">`Cron job not executing`</span>

**Solution:**
<span class="hljs-number">1.</span> Verify cron job is enabled
<span class="hljs-number">2.</span> Check gateway status: <span class="hljs-string">`openclaw gateway status`</span>
<span class="hljs-number">3.</span> Review cron logs: <span class="hljs-string">`openclaw cron list`</span>

## Best Practices

<span class="hljs-number">1.</span> **Test First:** Always test <span class="hljs-keyword">with</span> a draft post
<span class="hljs-number">2.</span> **Backup Content:** Keep markdown copies <span class="hljs-keyword">in</span> workspace
<span class="hljs-number">3.</span> **Version Control:** Track HASHNODE.md <span class="hljs-keyword">in</span> git (exclude token!)
<span class="hljs-number">4.</span> **Monitor Logs:** Check OpenClaw logs <span class="hljs-keyword">for</span> errors
<span class="hljs-number">5.</span> **Rate Limits:** Hashnode has API limits, don<span class="hljs-string">'t spam

## Security Tips

1. **Never commit tokens:** Add HASHNODE.md to .gitignore
2. **Use environment variables:** For production setups
3. **Rotate tokens:** Update tokens regularly
4. **Limit permissions:** Use minimal scope tokens

## Example Workflow

Complete workflow for publishing a technical post:

```bash
# 1. Write content
cat &gt; my-post.md &lt;&lt; EOF
# My Technical Post

Content here...
EOF

# 2. Ask OpenClaw to publish
# (In chat interface)
Publish the content from my-post.md to Hashnode

# 3. OpenClaw responds with:
# ✅ Post published!
# URL: https://notes.masud.pro/my-technical-post

# 4. Verify in browser
# Open the URL to check</span>
</code></pre><h2 id="heading-next-steps">Next Steps</h2>
<ul>
<li>Set up daily documentation automation</li>
<li>Create weekly RND report cron</li>
<li>Integrate with GitHub for release notes</li>
<li>Build custom publishing workflows</li>
</ul>
<h2 id="heading-resources">Resources</h2>
<ul>
<li>Hashnode GraphQL Docs: https://gql.hashnode.com/</li>
<li>OpenClaw Documentation: https://docs.openclaw.ai</li>
<li>Markdown Guide: https://www.markdownguide.org/</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[How to Publish Blog Posts on Hashnode Manually]]></title><description><![CDATA[How to Publish Blog Posts on Hashnode Manually
Complete guide to publish blog posts on Hashnode using GraphQL API.
Prerequisites

Hashnode account with a blog
Personal Access Token
Blog URL

Step 1: Get Your Hashnode Token

Go to Hashnode
Click on Se...]]></description><link>https://notes.masud.pro/how-to-publish-blog-posts-on-hashnode-manually</link><guid isPermaLink="true">https://notes.masud.pro/how-to-publish-blog-posts-on-hashnode-manually</guid><dc:creator><![CDATA[Masud Rana]]></dc:creator><pubDate>Thu, 09 Apr 2026 04:47:52 GMT</pubDate><enclosure url="https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=1200&amp;h=630&amp;fit=crop" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1 id="heading-how-to-publish-blog-posts-on-hashnode-manually">How to Publish Blog Posts on Hashnode Manually</h1>
<p>Complete guide to publish blog posts on Hashnode using GraphQL API.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ol>
<li>Hashnode account with a blog</li>
<li>Personal Access Token</li>
<li>Blog URL</li>
</ol>
<h2 id="heading-step-1-get-your-hashnode-token">Step 1: Get Your Hashnode Token</h2>
<ol>
<li>Go to <a target="_blank" href="https://hashnode.com">Hashnode</a></li>
<li>Click on Settings → API Tokens</li>
<li>Click "Generate new token"</li>
<li>Copy the token (save it securely)</li>
</ol>
<h2 id="heading-step-2-get-your-publication-id">Step 2: Get Your Publication ID</h2>
<p>Run this GraphQL query:</p>
<pre><code class="lang-bash">curl -X POST https://gql.hashnode.com/ \
  -H <span class="hljs-string">"Content-Type: application/json"</span> \
  -H <span class="hljs-string">"Authorization: YOUR_TOKEN_HERE"</span> \
  -d <span class="hljs-string">'{
    "query": "query { me { username publications(first: 10) { edges { node { id title url } } } } }"
  }'</span>
</code></pre>
<p>Response:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"data"</span>: {
    <span class="hljs-attr">"me"</span>: {
      <span class="hljs-attr">"username"</span>: <span class="hljs-string">"your-username"</span>,
      <span class="hljs-attr">"publications"</span>: {
        <span class="hljs-attr">"edges"</span>: [
          {
            <span class="hljs-attr">"node"</span>: {
              <span class="hljs-attr">"id"</span>: <span class="hljs-string">"6875dce83b0919f1c1394d62"</span>,
              <span class="hljs-attr">"title"</span>: <span class="hljs-string">"Your Blog Title"</span>,
              <span class="hljs-attr">"url"</span>: <span class="hljs-string">"https://yourblog.hashnode.dev"</span>
            }
          }
        ]
      }
    }
  }
}
</code></pre>
<p>Copy the <code>id</code> field - this is your <strong>Publication ID</strong>.</p>
<h2 id="heading-step-3-prepare-your-blog-post">Step 3: Prepare Your Blog Post</h2>
<p>Write your content in Markdown format:</p>
<pre><code class="lang-markdown">---
<span class="hljs-section">title: Your Post Title
---</span>

<span class="hljs-section"># Introduction</span>

Your content here...

<span class="hljs-section">## Features</span>

<span class="hljs-bullet">-</span> Feature 1
<span class="hljs-bullet">-</span> Feature 2

<span class="hljs-section">## Conclusion</span>

Wrap up!
</code></pre>
<h2 id="heading-step-4-publish-the-post">Step 4: Publish the Post</h2>
<p>Use this GraphQL mutation:</p>
<pre><code class="lang-bash">curl -X POST https://gql.hashnode.com/ \
  -H <span class="hljs-string">"Content-Type: application/json"</span> \
  -H <span class="hljs-string">"Authorization: YOUR_TOKEN_HERE"</span> \
  -d <span class="hljs-string">'{
    "query": "mutation PublishPost($input: PublishPostInput!) { publishPost(input: $input) { post { title url slug } } }",
    "variables": {
      "input": {
        "publicationId": "YOUR_PUBLICATION_ID",
        "title": "Your Post Title",
        "contentMarkdown": "# Your Post Title\n\nYour markdown content here..."
      }
    }
  }'</span>
</code></pre>
<p>Response:</p>
<pre><code class="lang-json">{
  <span class="hljs-attr">"data"</span>: {
    <span class="hljs-attr">"publishPost"</span>: {
      <span class="hljs-attr">"post"</span>: {
        <span class="hljs-attr">"title"</span>: <span class="hljs-string">"Your Post Title"</span>,
        <span class="hljs-attr">"url"</span>: <span class="hljs-string">"https://yourblog.hashnode.dev/your-post-slug"</span>,
        <span class="hljs-attr">"slug"</span>: <span class="hljs-string">"your-post-slug"</span>
      }
    }
  }
}
</code></pre>
<h2 id="heading-step-5-delete-a-post-optional">Step 5: Delete a Post (Optional)</h2>
<p>First, get the post ID:</p>
<pre><code class="lang-bash">curl -X POST https://gql.hashnode.com/ \
  -H <span class="hljs-string">"Content-Type: application/json"</span> \
  -H <span class="hljs-string">"Authorization: YOUR_TOKEN_HERE"</span> \
  -d <span class="hljs-string">'{
    "query": "query { publication(host: \"yourblog.hashnode.dev\") { posts(first: 10) { edges { node { id slug title } } } } }"
  }'</span>
</code></pre>
<p>Then delete:</p>
<pre><code class="lang-bash">curl -X POST https://gql.hashnode.com/ \
  -H <span class="hljs-string">"Content-Type: application/json"</span> \
  -H <span class="hljs-string">"Authorization: YOUR_TOKEN_HERE"</span> \
  -d <span class="hljs-string">'{
    "query": "mutation RemovePost($input: RemovePostInput!) { removePost(input: $input) { post { slug } } }",
    "variables": {
      "input": {
        "id": "POST_ID_HERE"
      }
    }
  }'</span>
</code></pre>
<h2 id="heading-common-issues">Common Issues</h2>
<h3 id="heading-1-invalid-token">1. Invalid Token</h3>
<ul>
<li>Error: <code>Unauthorized</code></li>
<li>Solution: Check your token is correct and not expired</li>
</ul>
<h3 id="heading-2-wrong-publication-id">2. Wrong Publication ID</h3>
<ul>
<li>Error: <code>Publication not found</code></li>
<li>Solution: Get your publication ID again from Step 2</li>
</ul>
<h3 id="heading-3-markdown-format">3. Markdown Format</h3>
<ul>
<li>Error: <code>Invalid markdown</code></li>
<li>Solution: Escape quotes and special characters properly</li>
</ul>
<h2 id="heading-example-complete-script">Example: Complete Script</h2>
<p>Save this as <code>publish.sh</code>:</p>
<pre><code class="lang-bash"><span class="hljs-meta">#!/bin/bash</span>

TOKEN=<span class="hljs-string">"your_token_here"</span>
PUB_ID=<span class="hljs-string">"your_publication_id"</span>
TITLE=<span class="hljs-string">"My New Post"</span>
CONTENT=<span class="hljs-string">"# My New Post\n\nThis is my content..."</span>

curl -X POST https://gql.hashnode.com/ \
  -H <span class="hljs-string">"Content-Type: application/json"</span> \
  -H <span class="hljs-string">"Authorization: <span class="hljs-variable">$TOKEN</span>"</span> \
  -d <span class="hljs-string">"{
    \"query\": \"mutation PublishPost(\$input: PublishPostInput!) { publishPost(input: \$input) { post { title url slug } } }\",
    \"variables\": {
      \"input\": {
        \"publicationId\": \"<span class="hljs-variable">$PUB_ID</span>\",
        \"title\": \"<span class="hljs-variable">$TITLE</span>\",
        \"contentMarkdown\": \"<span class="hljs-variable">$CONTENT</span>\"
      }
    }
  }"</span>
</code></pre>
<p>Make it executable:</p>
<pre><code class="lang-bash">chmod +x publish.sh
./publish.sh
</code></pre>
<h2 id="heading-next-steps">Next Steps</h2>
<p>Now you can:</p>
<ul>
<li>Automate with cron jobs</li>
<li>Integrate with CI/CD</li>
<li>Build custom tools</li>
<li>Use with OpenClaw for AI-powered blogging</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Mount NTFS Partition with Desktop Shortcut in Ubuntu]]></title><description><![CDATA[🔧 Problem
When I plugged in my NTFS drive, Ubuntu gave me errors like: Error mounting /dev/sdb2 at /media/fatboy/win_128: wrong fs type, bad superblock on /dev/sdb2, missing codepage or helper program
This usually happens when:

NTFS support (ntfs-3...]]></description><link>https://notes.masud.pro/mount-ntfs-partition-with-desktop-shortcut-in-ubuntu</link><guid isPermaLink="true">https://notes.masud.pro/mount-ntfs-partition-with-desktop-shortcut-in-ubuntu</guid><dc:creator><![CDATA[Masud Rana]]></dc:creator><pubDate>Fri, 19 Sep 2025 21:31:21 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1758318743015/0d5b3d19-4273-451b-8249-b0a4f31e96c6.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h2 id="heading-problem">🔧 Problem</h2>
<p>When I plugged in my NTFS drive, Ubuntu gave me errors like: Error mounting /dev/sdb2 at /media/fatboy/win_128: wrong fs type, bad superblock on /dev/sdb2, missing codepage or helper program</p>
<p>This usually happens when:</p>
<ul>
<li><p>NTFS support (<code>ntfs-3g</code>) is missing</p>
</li>
<li><p>The drive was not safely removed in Windows (hibernated / Fast Startup enabled)</p>
</li>
<li><p>The drive needs manual mounting instead of automatic GUI mounting</p>
</li>
</ul>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1758316404115/e9f65a23-e17d-4984-b8ea-1e074a8e0644.png" alt class="image--center mx-auto" /></p>
<h2 id="heading-solution-overview">✅ Solution Overview</h2>
<ol>
<li><p>Install NTFS support (<code>ntfs-3g</code>)</p>
</li>
<li><p>Create a mount point (<code>/media/$USER/win_128</code>)</p>
</li>
<li><p>Mount the drive manually to test</p>
</li>
<li><p>Create a <code>.desktop</code> launcher file to mount with a click</p>
</li>
<li><p>Trust the launcher so GNOME lets it run</p>
</li>
</ol>
<h2 id="heading-step-1-install-ntfs-support">⚙️ Step 1: Install NTFS Support</h2>
<pre><code class="lang-bash">sudo apt update
sudo apt install ntfs-3g
</code></pre>
<p>📂 Step 2: Create a Mount Point</p>
<pre><code class="lang-bash">sudo mkdir -p /media/<span class="hljs-variable">$USER</span>/win_128
</code></pre>
<p>💻 Step 3: Mount the Drive Manually</p>
<pre><code class="lang-bash">sudo mount -t ntfs-3g /dev/sdb2 /media/<span class="hljs-variable">$USER</span>/win_128
ls /media/<span class="hljs-variable">$USER</span>/win_128
</code></pre>
<p>🖥 Step 4: Create a Desktop Launcher</p>
<pre><code class="lang-bash">sudo nano ~/Desktop/mount_win_128.desktop
</code></pre>
<pre><code class="lang-bash">[Desktop Entry]
Version=1.0
Type=Application
Name=Mount Win_128
Comment=Mount NTFS partition win_128
Exec=sh -c <span class="hljs-string">'pkexec mount -t ntfs-3g /dev/sdb2 /media/$USER/win_128'</span>
Icon=drive-harddisk
Terminal=<span class="hljs-literal">false</span>
Categories=Utility;
</code></pre>
<p>🔒 Step 5: Allow Launching</p>
<pre><code class="lang-bash">chmod +x ~/Desktop/mount_win_128.desktop
gio <span class="hljs-built_in">set</span> ~/Desktop/mount_win_128.desktop metadata::trusted <span class="hljs-literal">true</span>
</code></pre>
<p>🛑 Bonus: Unmount Shortcut</p>
<pre><code class="lang-bash">[Desktop Entry]
Version=1.0
Type=Application
Name=Unmount Win_128
Comment=Unmount NTFS partition win_128
Exec=sh -c <span class="hljs-string">'pkexec umount /media/$USER/win_128'</span>
Icon=media-eject
Terminal=<span class="hljs-literal">false</span>
Categories=Utility;
</code></pre>
<p>🛑 Bonus: Unmount Shortcut 2.0</p>
<pre><code class="lang-bash">sudo nano ~/Desktop/toggle_mount.desktop
sudo chmod +x ~/Desktop/toggle_mount.desktop
gio <span class="hljs-built_in">set</span> ~/Desktop/toggle_mount.desktop metadata::trusted <span class="hljs-literal">true</span>
</code></pre>
<pre><code class="lang-bash">[Desktop Entry]
Version=1.0
Type=Application
Name=Toggle Win_128
Comment=Mount/Unmount NTFS partition win_128
Icon=drive-harddisk
Terminal=<span class="hljs-literal">false</span>
Categories=Utility;
Exec=sh -c <span class="hljs-string">'MP="/media/$USER/win_128"; DEV="/dev/sdb2"; mkdir -p "$MP"; if mountpoint -q "$MP"; then pkexec umount "$MP" &amp;&amp; notify-send "Win_128" "Unmounted $D&gt;</span>
</code></pre>
<h3 id="heading-result-now-i-can-mount-and-unmount-my-windows-partition-with-a-single-click">🎯 Result Now I can mount and unmount my Windows partition with a single click.</h3>
]]></content:encoded></item></channel></rss>