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:
- Generates a structured article draft.
- Runs a separate refinement pass.
- Processes generation through Laravel queues.
- Exposes the workflow through a REST API.
- 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.

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.

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.

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.