Skip to main content

Command Palette

Search for a command to run...

Adding AI-Powered Product Descriptions to Your Shopify App

Updated
6 min read
Adding AI-Powered Product Descriptions to Your Shopify App

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 usage-based billing. Everything you need to ship an AI-powered Shopify app feature.


The Opportunity

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.

Here's how to build it end to end.


1. OpenAI Integration in Laravel

Start by installing the OpenAI PHP client:

composer require openai-php/laravel

Publish the config and add your API key:

OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4o-mini

The Description Generator Service

Create a clean service class that handles prompt engineering and API calls:

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

use OpenAI\Laravel\Facades\OpenAI;
use App\Models\Shop;

class DescriptionGenerator
{
    public function generate(Shop $shop, array $product, string $tone = 'professional'): string
    {
        $prompt = $this->buildPrompt($product, $tone);

        $response = OpenAI::chat()->create([
            'model' => config('openai.model', 'gpt-4o-mini'),
            'messages' => [
                ['role' => 'system', 'content' => $this->systemPrompt()],
                ['role' => 'user', 'content' => $prompt],
            ],
            'max_tokens' => 500,
            'temperature' => 0.7,
        ]);

        return $response->choices[0]->message->content;
    }

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

    private function buildPrompt(array $product, string $tone): string
    {
        return "Generate a product description for:\n\n"
             . "Title: {$product['title']}\n"
             . "Price: {$product['price']}\n"
             . "Category: " . ($product['product_type'] ?? 'General') . "\n"
             . "Tags: " . implode(', ', $product['tags'] ?? []) . "\n"
             . "Current description: " . ($product['body_html'] ?? 'None') . "\n\n"
             . "Tone: {$tone}\n"
             . "Length: 2-3 paragraphs with bullet points for key features.";
    }
}

Handling Token Limits and Errors

// app/Services/DescriptionGenerator.php (extended)
public function generateWithRetry(Shop $shop, array $product, string $tone = 'professional', int $maxRetries = 2): ?string
{
    for ($attempt = 0; $attempt <= $maxRetries; $attempt++) {
        try {
            $description = $this->generate($shop, $product, $tone);

            // Track usage for billing
            $shop->increment('ai_tokens_used', strlen($description));

            return $description;
        } catch (\OpenAI\Exceptions\ErrorException $e) {
            if (str_contains($e->getMessage(), 'rate_limit') && $attempt < $maxRetries) {
                sleep(pow(2, $attempt)); // Exponential backoff
                continue;
            }
            logger()->error('OpenAI generation failed', [
                'shop' => $shop->domain,
                'product' => $product['id'] ?? null,
                'error' => $e->getMessage(),
            ]);
            return null;
        }
    }
    return null;
}

2. React Embed in Shopify Admin with Polaris

Your app lives inside Shopify's admin as an embedded app. You need a React frontend using Shopify's Polaris component library.

Setting Up the Frontend

npm create vite@latest frontend -- --template react
cd frontend
npm install @shopify/polaris @shopify/app-bridge-react

The Description Generator UI

// frontend/src/components/DescriptionGenerator.jsx
import { useState } from 'react';
import {
    Card,
    TextField,
    Select,
    Button,
    TextContainer,
    SkeletonBodyText,
    Banner,
    Stack,
    Layout,
} from '@shopify/polaris';

export function DescriptionGenerator() {
    const [product, setProduct] = useState(null);
    const [tone, setTone] = useState('professional');
    const [generated, setGenerated] = useState('');
    const [loading, setLoading] = useState(false);

    const tones = [
        { label: 'Professional', value: 'professional' },
        { label: 'Casual', value: 'casual' },
        { label: 'Luxury', value: 'luxury' },
        { label: 'Technical', value: 'technical' },
        { label: 'Playful', value: 'playful' },
    ];

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

    return (
        <Layout>
            <Layout.Section>
                <Card sectioned>
                    <TextContainer>
                        <TextField
                            label="Product ID or Title"
                            value={product}
                            onChange={setProduct}
                        />
                        <Select
                            label="Tone"
                            options={tones}
                            value={tone}
                            onChange={setTone}
                        />
                        <Button primary onClick={handleGenerate} loading={loading}>
                            Generate Description
                        </Button>
                    </TextContainer>
                </Card>
            </Layout.Section>
            <Layout.Section>
                {loading ? (
                    <Card sectioned>
                        <SkeletonBodyText lines={6} />
                    </Card>
                ) : generated ? (
                    <Card sectioned title="Generated Description">
                        <div dangerouslySetInnerHTML={{ __html: generated }} />
                        <Stack distribution="trailing">
                            <Button onClick={() => navigator.clipboard.writeText(generated)}>
                                Copy
                            </Button>
                            <Button primary onClick={handleApply}>
                                Apply to Product
                            </Button>
                        </Stack>
                    </Card>
                ) : null}
            </Layout.Section>
        </Layout>
    );
}

Laravel API Endpoint

// app/Http/Controllers/Api/DescriptionController.php
namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Services\DescriptionGenerator;
use App\Models\Product;

class DescriptionController extends Controller
{
    public function __construct(
        private DescriptionGenerator $generator
    ) {}

    public function generate(Request $request)
    {
        $request->validate([
            'product_id' => 'required|string',
            'tone' => 'sometimes|in:professional,casual,luxury,technical,playful',
        ]);

        $shop = $request->attributes->get('shop');
        $product = Product::where('shop_id', $shop->id)
            ->where('shopify_product_id', $request->product_id)
            ->firstOrFail();

        $description = $this->generator->generateWithRetry(
            $shop,
            $product->toShopifyArray(),
            $request->tone ?? 'professional'
        );

        if (!$description) {
            return response()->json(['error' => 'Generation failed'], 500);
        }

        return response()->json(['description' => $description]);
    }
}

3. Bulk Generation with Queues

Merchants with 500+ products won't generate descriptions one at a time. Bulk generation is essential.

The Bulk Job

// app/Jobs/BulkGenerateDescriptions.php
namespace App\Jobs;

use Illuminate\Bus\{Batchable, Queueable};
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\{InteractsWithQueue, SerializesModels};
use Illuminate\Support\Facades\Bus;
use App\Services\DescriptionGenerator;
use App\Models\Product;
use App\Models\BulkJob;

class GenerateSingleDescription implements ShouldQueue
{
    use Batchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 2;
    public int $backoff = 10;

    public function __construct(
        public BulkJob $bulkJob,
        public int $productId
    ) {}

    public function handle(DescriptionGenerator $generator): void
    {
        if ($this->batch()?->cancelled()) {
            return;
        }

        $product = Product::find($this->productId);
        $description = $generator->generateWithRetry(
            $product->shop,
            $product->toShopifyArray(),
            $this->bulkJob->tone
        );

        if ($description) {
            $product->update(['generated_description' => $description]);
        }

        $this->bulkJob->increment('processed_count');
    }
}

Dispatching the Batch

// app/Http/Controllers/Api/BulkController.php
namespace App\Http\Controllers\Api;

use Illuminate\Support\Facades\Bus;
use App\Jobs\GenerateSingleDescription;
use App\Models\BulkJob;

class BulkController extends Controller
{
    public function start(Request $request)
    {
        $shop = $request->attributes->get('shop');

        $products = $shop->products()
            ->whereNull('generated_description')
            ->pluck('id');

        $bulkJob = BulkJob::create([
            'shop_id' => $shop->id,
            'total_count' => $products->count(),
            'processed_count' => 0,
            'tone' => $request->tone ?? 'professional',
            'status' => 'processing',
        ]);

        $jobs = $products->map(
            fn($id) => new GenerateSingleDescription($bulkJob, $id)
        );

        $batch = Bus::batch($jobs)
            ->name("bulk-descriptions-{$shop->id}")
            ->allowFailures()
            ->finally(function () use ($bulkJob) {
                $bulkJob->update(['status' => 'completed']);
            })
            ->dispatch();

        $bulkJob->update(['batch_id' => $batch->id]);

        return response()->json([
            'bulk_job_id' => $bulkJob->id,
            'total' => $products->count(),
        ]);
    }
}

This uses Laravel's job batching — you get progress tracking, cancellation support, and failure handling out of the box.


4. Usage-Based Billing

Shopify supports usage-based billing through the Billing API. You charge merchants based on how many descriptions they generate.

Setting Up a Usage Charge

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

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

class ShopifyBilling
{
    public function createUsageRecord(Shop $shop, int $units, float $pricePerUnit = 0.05): void
    {
        $mutation = <<<'GraphQL'
        mutation appUsageRecordCreate($id: ID!, $description: String!, $price: Decimal!, $url: URL) {
            appUsageRecordCreate(input: {
                appSubscriptionLineItemId: $id
                description: $description
                price: { amount: $price, currencyCode: USD }
            }) {
                userErrors { field message }
                appUsageRecord { id }
            }
        }
        GraphQL;

        Http::withHeaders(['X-Shopify-Access-Token' => $shop->access_token])
            ->post("https://{$shop->domain}/admin/api/2024-01/graphql.json", [
                'query' => $mutation,
                'variables' => [
                    'id' => $shop->subscription_line_item_id,
                    'description' => "AI Description Generation ({$units} descriptions)",
                    'price' => $units * $pricePerUnit,
                ],
            ]);
    }
}

Tracking Usage Locally

Keep a local counter so you can bill at intervals:

// In your GenerateSingleDescription job, after successful generation:
$shop->increment('descriptions_generated');
$shop->increment('ai_credits_used');

// Check if we should bill
if ($shop->ai_credits_used >= $shop->billing_threshold) {
    app(ShopifyBilling::class)->createUsageRecord(
        $shop,
        $shop->ai_credits_used
    );
    $shop->update(['ai_credits_used' => 0]);
}

Pricing Model Example

A simple model that works well:

  • Free tier: 10 descriptions/month
  • Pro: $9.99/month base + $0.05 per description over 100
  • Enterprise: $49.99/month with 1,000 descriptions included

Putting It All Together

The flow looks like this:

  1. Merchant selects products in your Polaris UI
  2. Frontend calls your Laravel API
  3. API dispatches batch jobs to the queue
  4. Each job calls OpenAI, saves the description, and tracks usage
  5. Usage gets billed through Shopify's Billing API
  6. Merchant sees progress and reviews generated descriptions in the UI

The stack is: Laravel (backend) + React/Polaris (frontend) + Redis (queues) + OpenAI (AI) + Shopify Billing (payments).

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.

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.

More from this blog

M

Masud Rana

7 posts

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