Laravel Daily's

Build an AI Content Generation Pipeline in Laravel: Draft, Refine, and Publish with the AI SDK

Laravel AI content generation pipeline showing draft, refine, queue, and publish stages

AI content generation works best as a pipeline, not a single model call.

A draft needs structure. A refinement pass needs different instructions. Long-running generation should leave the HTTP request. The final result needs a stable API contract.

Laravel’s AI SDK gives you these building blocks through one Laravel-native interface. In this tutorial, we will build a pipeline that:

  1. Generates a structured article draft.
  2. Runs a separate refinement pass.
  3. Processes generation through Laravel queues.
  4. Exposes the workflow through a REST API.
  5. Switches between OpenAI and Anthropic through configuration.

The examples use Laravel 13 conventions and assume an existing Content model.

The pipeline architecture

The application will store each generation request in a contents table.

Each record moves through these states:

queued → drafting → refining → review

The pipeline has two jobs:

  • GenerateDraft: creates the first structured article.
  • RefineDraft: improves the draft and marks it ready for review.

Publishing remains a deliberate application action. AI can prepare content, but your editorial workflow should decide when content becomes public.

Install and configure the Laravel AI SDK

Install the SDK with Composer:

composer require laravel/ai

Publish the SDK configuration and migrations:

php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

Add credentials for both providers to .env:

OPENAI_API_KEY=your-openai-key
ANTHROPIC_API_KEY=your-anthropic-key

QUEUE_CONNECTION=database

If your application does not already have the database queue migration, create it:

php artisan make:queue-table
php artisan migrate

The AI SDK stores provider and model defaults in config/ai.php. Keep your application’s default provider behind one environment variable:

// config/ai.php

'default' => env('AI_PROVIDER', 'openai'),

The published provider configuration should include both OpenAI and Anthropic:

'providers' => [
    'openai' => [
        'driver' => 'openai',
        'key' => env('OPENAI_API_KEY'),
    ],

    'anthropic' => [
        'driver' => 'anthropic',
        'key' => env('ANTHROPIC_API_KEY'),
    ],
],

The exact model defaults can remain in the published configuration. This keeps provider details out of your application code.

Create a structured content agent

Generate an agent with structured output enabled:

php artisan make:agent ContentWriter --structured

Update app/Ai/Agents/ContentWriter.php:

<?php

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;

class ContentWriter implements Agent, HasStructuredOutput
{
    use Promptable;

    public function instructions(): string
    {
        return <<<'PROMPT'
            You create editorial content for a technical web publication.

            Follow the requested audience, tone, and topic.
            Avoid unsupported claims and unnecessary filler.
            Return a complete article object that matches the schema.
            PROMPT;
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'title' => $schema->string()->required(),
            'slug' => $schema->string()->required(),
            'excerpt' => $schema->string()->required(),
            'body' => $schema->string()->required(),
            'tags' => $schema->array()
                ->items($schema->string())
                ->required(),
        ];
    }
}

HasStructuredOutput prevents your application from parsing JSON embedded in model text. The SDK sends the schema to the provider and returns a structured response that you can access like an array.

Generate a draft from your application code:

use App\Ai\Agents\ContentWriter;

$response = (new ContentWriter)->prompt(<<<'PROMPT'
    Create a 900-word article.

    Topic: Laravel queue workers
    Audience: PHP developers building production applications
    Tone: practical and technical
    Include: setup steps, deployment advice, and common mistakes
    PROMPT);

$content->update([
    'title' => $response['title'],
    'slug' => $response['slug'],
    'excerpt' => $response['excerpt'],
    'body' => $response['body'],
    'tags' => $response['tags'],
]);

The response contains predictable fields:

$response['title'];
$response['body'];
$response['tags'];

That makes the output suitable for database storage, API responses, moderation screens, and later refinement.

Structured output flow showing a Laravel AI agent producing JSON content and a refinement pass

Add a refinement pass

Drafting and editing are different tasks. Use a second prompt with a narrower responsibility.

The refinement pass should preserve the article’s subject while checking structure, clarity, repetition, and tone:

$draft = $content->only([
    'title',
    'slug',
    'excerpt',
    'body',
    'tags',
]);

$response = (new ContentWriter)->prompt(<<<PROMPT
    Refine the following article.

    Preserve its factual meaning and intended audience.
    Improve the structure, transitions, clarity, and technical precision.
    Remove repetition and unsupported claims.
    Return the complete replacement object using the required schema.

    ARTICLE:
    {$draft['body']}
    PROMPT);

$content->update([
    'title' => $response['title'],
    'slug' => $response['slug'],
    'excerpt' => $response['excerpt'],
    'body' => $response['body'],
    'tags' => $response['tags'],
]);

A separate agent class can provide stricter editorial boundaries. For example, a ContentRefiner agent could reject promotional language or require a specific heading structure.

The important design choice is separation. Each pass has one job, one prompt, and one output contract.

Queue generation jobs

AI requests can take longer than a normal web request. Move them to a queue before exposing the workflow to users.

Create the first job:

php artisan make:job GenerateDraft
php artisan make:job RefineDraft

app/Jobs/GenerateDraft.php:

<?php

namespace App\Jobs;

use App\Ai\Agents\ContentWriter;
use App\Models\Content;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class GenerateDraft implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Content $content,
    ) {}

    public function handle(): void
    {
        $this->content->update(['status' => 'drafting']);

        $response = (new ContentWriter)->prompt(<<<PROMPT
            Create a technical article for the following brief.

            Topic: {$this->content->topic}
            Audience: {$this->content->audience}
            Tone: {$this->content->tone}

            Return a complete article object.
            PROMPT);

        $this->content->update([
            'title' => $response['title'],
            'slug' => $response['slug'],
            'excerpt' => $response['excerpt'],
            'body' => $response['body'],
            'tags' => $response['tags'],
            'status' => 'drafted',
        ]);

        RefineDraft::dispatch($this->content->fresh())
            ->onQueue('ai');
    }
}

app/Jobs/RefineDraft.php:

<?php

namespace App\Jobs;

use App\Ai\Agents\ContentWriter;
use App\Models\Content;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class RefineDraft implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public Content $content,
    ) {}

    public function handle(): void
    {
        $this->content->update(['status' => 'refining']);

        $response = (new ContentWriter)->prompt(<<<PROMPT
            Refine this technical article.

            Keep the factual meaning.
            Improve clarity, structure, and grammar.
            Use short paragraphs and descriptive headings.
            Return the complete replacement object.

            Title: {$this->content->title}
            Body:
            {$this->content->body}
            PROMPT);

        $this->content->update([
            'title' => $response['title'],
            'slug' => $response['slug'],
            'excerpt' => $response['excerpt'],
            'body' => $response['body'],
            'tags' => $response['tags'],
            'status' => 'review',
        ]);
    }
}

Dispatch the first job after creating the record:

$content = Content::create([
    'topic' => $request->string('topic'),
    'audience' => $request->string('audience'),
    'tone' => $request->string('tone', 'technical'),
    'status' => 'queued',
]);

GenerateDraft::dispatch($content)->onQueue('ai');

Run a worker locally:

php artisan queue:work --queue=ai,default

Laravel’s queue system supports database, Redis, Amazon SQS, and other backends. For production, use a process monitor or Laravel Cloud to keep workers running.

The AI SDK also supports queueing an agent directly with queue(). Custom jobs are useful here because they make state changes, retries, and pipeline stages explicit.

Laravel queue illustration showing content jobs moving through a worker into a database

Build a REST API with PHP

Now expose the pipeline through an API. This is where Laravel works well as a PHP web framework: validation, routing, queues, resources, and authorization use the same application conventions.

Create routes in routes/api.php:

use App\Http\Controllers\ContentController;
use Illuminate\Support\Facades\Route;

Route::post('/content', [ContentController::class, 'store']);
Route::get('/content/{content}', [ContentController::class, 'show']);

Create the controller:

php artisan make:controller ContentController
php artisan make:resource ContentResource
<?php

namespace App\Http\Controllers;

use App\Http\Resources\ContentResource;
use App\Jobs\GenerateDraft;
use App\Models\Content;
use Illuminate\Http\Request;

class ContentController extends Controller
{
    public function store(Request $request)
    {
        $data = $request->validate([
            'topic' => ['required', 'string', 'max:200'],
            'audience' => ['required', 'string', 'max:200'],
            'tone' => ['nullable', 'string', 'max:100'],
        ]);

        $content = Content::create([
            ...$data,
            'tone' => $data['tone'] ?? 'technical',
            'status' => 'queued',
        ]);

        GenerateDraft::dispatch($content)->onQueue('ai');

        return (new ContentResource($content))
            ->response()
            ->setStatusCode(202);
    }

    public function show(Content $content)
    {
        return new ContentResource($content);
    }
}

Return a stable JSON shape through ContentResource:

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ContentResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'status' => $this->status,
            'title' => $this->title,
            'slug' => $this->slug,
            'excerpt' => $this->excerpt,
            'body' => $this->body,
            'tags' => $this->tags,
            'created_at' => $this->created_at,
        ];
    }
}

A client can now submit a generation request:

POST /api/content
Content-Type: application/json

{
    "topic": "Laravel queue workers",
    "audience": "PHP developers",
    "tone": "practical"
}

The API returns 202 Accepted immediately:

{
    "data": {
        "id": 42,
        "status": "queued",
        "title": null,
        "body": null
    }
}

The client can poll GET /api/content/42 until the status becomes review. For larger applications, broadcast status changes with events or provide a webhook.

Laravel REST API illustration showing POST content routes, queue status, JSON responses, and provider switching

Switch providers without changing agent code

The agent does not need to know whether OpenAI or Anthropic handles the request.

With this configuration:

'default' => env('AI_PROVIDER', 'openai'),

Use OpenAI:

AI_PROVIDER=openai

Switch to Anthropic:

AI_PROVIDER=anthropic

That is the only application-level change required. The ContentWriter, jobs, controller, and resource remain unchanged.

For a request-specific override, the AI SDK also accepts a provider argument:

use Laravel\Ai\Enums\Lab;

$response = (new ContentWriter)->prompt(
    'Refine this article.',
    provider: Lab::Anthropic,
);

Use the configuration-based approach for normal deployments. Use per-request overrides for experiments, staged rollouts, or provider-specific workloads. The SDK also supports provider failover when you provide an ordered list of providers.

Production checks

Before shipping, add a few safeguards:

  • Authorize who can create and publish content.
  • Limit topic and prompt lengths.
  • Store provider usage and request IDs for debugging.
  • Add retry and timeout settings to AI jobs.
  • Use Queue::fake() and the SDK’s agent fakes in tests.
  • Keep generated content in review until a person approves it.
  • Avoid logging sensitive prompts or private source material.
  • Monitor queue depth and failed jobs.

The AI SDK testing documentation includes assertions for prompts, queued agents, and structured responses. The queue documentation covers retries, timeouts, failed jobs, and worker deployment.

A content pipeline becomes easier to maintain when each stage has a narrow responsibility. Laravel supplies the application structure, the AI SDK supplies the provider abstraction, and queues keep generation away from the request cycle. Together, they give you a practical foundation for AI-powered publishing workflows.

Previous
Optimistic UI in Inertia 3.x: Instant Feedback with useHttp in Laravel + Vue
Next
Lock Down Your AI Agents: Validating Tool Arguments in Laravel with the AI SDK