AI-Powered Product Descriptions Generator in Laravel E-Commerce
800 products. Three days until the store launch. One copywriter who just handed in her notice.
That was the situation a client described to me before I built this. The answer wasn't a contractor. It was four hours of work in Laravel and a GPT-4o API key.
This article shows you how to build a product description generator that integrates cleanly with a Laravel e-commerce backend, handles different product categories and tone requirements, and scales to bulk generation via queues. The code uses the openai-php/laravel SDK — if you've been working with the Anthropic client, the structure's the same but the method signatures differ.
What You're Actually Building
A service class that takes a product record, builds a structured prompt from its attributes, and returns a polished 50–80 word description ready to persist. The output isn't a first draft you hand back to a copywriter. That's the point.
You get:
- Single-product generation via an authenticated API endpoint
- Batch generation capped at 20 products per request
- Queue-based bulk processing for full catalog passes
Installation
composer require openai-php/laravel
Publish the config and add your key:
php artisan vendor:publish --provider="OpenAI\Laravel\ServiceProvider"
OPENAI_API_KEY=sk-your-key-here
That's the setup.
The facade is available immediately — no extra binding required.
The Description Service
<?php
namespace App\Services;
use OpenAI\Laravel\Facades\OpenAI;
class ProductDescriptionService
{
private const TONES = [
'professional' => 'Write clear, benefit-focused product descriptions. Emphasize quality and practical value. No fluff.',
'casual' => 'Write friendly, conversational descriptions. Keep it readable and relatable.',
'luxury' => 'Write aspirational copy for a high-end audience. Focus on craftsmanship, exclusivity, and sensory experience.',
];
public function generate(array $product, string $tone = 'professional'): string
{
$response = OpenAI::chat()->create([
'model' => 'gpt-4o', // or gpt-4o-mini if cost is the constraint
'messages' => [
['role' => 'system', 'content' => $this->systemPrompt($tone)],
['role' => 'user', 'content' => $this->userPrompt($product)],
],
'max_tokens' => 300,
'temperature' => 0.7,
]);
return trim($response->choices[0]->message->content);
}
private function systemPrompt(string $tone): string
{
return self::TONES[$tone] ?? self::TONES['professional'];
}
private function userPrompt(array $product): string
{
$attrs = collect($product['attributes'] ?? [])
->filter()
->map(fn ($v, $k) => "- {$k}: {$v}")
->implode("\n");
$hint = $this->categoryHint($product['category'] ?? '');
return <<<PROMPT
Product: {$product['name']}
Category: {$product['category']}
{$hint}
Attributes:
{$attrs}
Write a product description of exactly 50–80 words. Lead with the main benefit, not the product name. No bullet points. No "Introducing..." opener. End on a strong note.
PROMPT;
}
private function categoryHint(string $category): string
{
return match (true) {
str_contains($category, 'Electronic') => 'Focus on performance specs, compatibility, and what problem it solves.',
str_contains($category, 'Apparel') => "Use tactile language. Mention fit, feel, and when you'd reach for it.",
str_contains($category, 'Home') => 'Emphasize how it improves daily life. Be concrete.',
str_contains($category, 'Food') => 'Describe flavor, sourcing, and suggested use.',
default => '',
};
}
}
Temperature at 0.7 gives you natural variation without the output going sideways. Drop to 0.3 if brand consistency matters more than freshness — though at that point everything starts to sound like it came from the same sentence generator, which is its own problem when you've got 500 products in the same category.
Wire Up the Controller
Two endpoints: single product and small batch.
<?php
namespace App\Http\Controllers;
use App\Models\Product;
use App\Services\ProductDescriptionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ProductDescriptionController extends Controller
{
public function __construct(
private readonly ProductDescriptionService $service
) {}
public function generate(Request $request): JsonResponse
{
$validated = $request->validate([
'product_id' => ['required', 'integer', 'exists:products,id'],
'tone' => ['sometimes', 'string', 'in:professional,casual,luxury'],
]);
$product = Product::findOrFail($validated['product_id']);
$description = $this->service->generate(
$product->toDescriptionArray(),
$validated['tone'] ?? 'professional'
);
$product->update(['ai_description' => $description]);
return response()->json([
'product_id' => $product->id,
'description' => $description,
]);
}
public function batch(Request $request): JsonResponse
{
$validated = $request->validate([
'product_ids' => ['required', 'array', 'max:20'],
'product_ids.*' => ['integer', 'exists:products,id'],
'tone' => ['sometimes', 'string', 'in:professional,casual,luxury'],
]);
$tone = $validated['tone'] ?? 'professional';
$results = [];
foreach (Product::findMany($validated['product_ids']) as $product) {
$results[$product->id] = $this->service->generate(
$product->toDescriptionArray(),
$tone
);
usleep(200000); // 200ms between calls, stays under the RPM limit
}
return response()->json(['descriptions' => $results]);
}
}
Add toDescriptionArray() to your Product model:
public function toDescriptionArray(): array
{
return [
'name' => $this->name,
'category' => $this->category->name ?? 'General',
'attributes' => array_filter([
'Material' => $this->material,
'Dimensions' => $this->dimensions,
'Color' => $this->color,
'Weight' => $this->weight,
]),
];
}
The array_filter matters. Null attributes don't help the model — they help it generate vague copy that sounds like every other description it's ever written.
Route it:
Route::middleware('auth:sanctum')->group(function () {
Route::post('/products/descriptions/generate', [ProductDescriptionController::class, 'generate']);
Route::post('/products/descriptions/batch', [ProductDescriptionController::class, 'batch']);
});
What Happens at 2,000 Products?
The batch endpoint works for manual updates or import runs. For a full catalog pass, synchronous requests don't scale. A queue does.
php artisan make:job GenerateProductDescriptions
<?php
namespace App\Jobs;
use App\Models\Product;
use App\Services\ProductDescriptionService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class GenerateProductDescriptions implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 10;
public function __construct(
private readonly array $productIds,
private readonly string $tone = 'professional'
) {}
public function handle(ProductDescriptionService $service): void
{
Product::findMany($this->productIds)
->each(function (Product $product) use ($service) {
$description = $service->generate(
$product->toDescriptionArray(),
$this->tone
);
$product->update(['ai_description' => $description]);
usleep(500000); // 500ms between products keeps you inside rate limits
});
}
}
Dispatch it in chunks:
Product::whereNull('ai_description')
->select('id')
->chunkById(25, function ($products) {
GenerateProductDescriptions::dispatch(
$products->pluck('id')->toArray()
);
});
At 500ms between calls, a 25-product job takes about 13 seconds. That's comfortably inside most queue worker timeout defaults. If you're on Laravel Horizon with a longer timeout configured, you can push the chunk size to 50 and drop the sleep — but test it against your actual rate limit tier first.
Where This Breaks
In my experience, roughly 80% of generated descriptions are publishable without edits. The remaining 20% fall into a few predictable categories.
Products with sparse attributes get generic copy. If material, dimensions, and color are all null, the model fills that gap with language so vague it reads like the description was written by someone who'd never seen the product. The array_filter in toDescriptionArray() helps by stripping nulls from the prompt, but you should also add a guard: if fewer than two attributes are present, flag the product for manual writing rather than letting the generator produce filler.
Highly technical products are the trickier case. An industrial-grade fitting or a specialized audio component needs domain knowledge that GPT-4o carries inconsistently. The descriptions are grammatically fine, logically plausible, and occasionally wrong in ways only a specialist would catch. That's worse than obviously bad output — it can make it through review.
Brand voice drift is the slow one. The first hundred descriptions look great. By description 800, the output has settled into patterns that technically meet the prompt constraints but feel slightly off. This is partly a temperature issue, partly a prompt issue. The more specific you make the system prompt — including actual examples from your copywriter's past work — the tighter the output stays over large catalog runs.
What You're Actually Signing Up For
The pipeline runs. What most write-ups skip is what happens three months after you ship it.
The 80% pass rate sounds great until you're running quarterly catalog refreshes and realize you need a review queue for the 20% that needs a human pass. Not hard to build, but not free either. You'll also want a re-generation trigger for products whose attributes change post-generation — either a model observer on product update or a freshness check in a scheduled job. The longer you run this in production, the more of these edge cases surface.
I also can't fully defend the 0.7 temperature choice. I've had descriptions that are factually accurate, brand-appropriate, and still somehow clunky in a way I can't articulate. Dropping to 0.4 eliminates that, at the cost of every description in the same category starting to rhyme with each other. I don't have a clean answer for this. My current approach is running both and periodically spot-checking which version my merchandising team prefers — which is a fiddly process and not a satisfying conclusion, but it's the honest one.
Share