Laravel officially introduced its AI SDK, and for teams shipping PHP in production this is a meaningful shift: it’s not just “adding AI” — it’s integrating models with Laravel-friendly structure, predictable code, and less ad-hoc glue logic.
You can now encapsulate prompts, model providers, and decision logic in a maintainable layer instead of scattering API calls inside controllers or jobs. Here’s how I use it in production with real code, tests, and technical governance.
Why It Matters in Real Products
In portfolio projects and SaaS apps, the highest-value AI use cases are operational, not conversational:
| Use Case | Real Value | Example Metric |
|---|---|---|
| Summarize tickets/long messages | -60% support reading time | 15s → 6s/ticket |
| Classify leads/forms | Auto-routing to right team | 95% accuracy |
| Generate content drafts | Scalable SEO/content marketing | 10 articles/week → 50 |
| Extract structured data | Automated reports from free text | 0 manual entry |
With Laravel AI SDK, these flows live as reusable application services with testable boundaries — not experiments in controllers.
Recommended Architecture: Separation of Concerns
app/
├── AI/
│ ├── Actions/ # One Action per use case
│ │ ├── SummarizeTicketAction.php
│ │ ├── ClassifyLeadAction.php
│ │ └── ExtractInvoiceDataAction.php
│ ├── Prompts/ # Versioned prompt templates
│ │ ├── SummarizeTicketPrompt.php
│ │ └── ClassifyLeadPrompt.php
│ ├── DTOs/ # Typed input/output contracts
│ │ ├── SummarizeTicketInput.php
│ │ ├── SummarizeTicketOutput.php
│ │ └── ClassifyLeadResult.php
│ ├── Policies/ # Governance: cost, rate-limit, fallback
│ │ ├── AICostPolicy.php
│ │ ├── AIRateLimitPolicy.php
│ │ └── AIFallbackPolicy.php
│ ├── Contracts/ # Interfaces for testability
│ │ └── AIProviderInterface.php
│ └── Providers/ # Implementations (OpenAI, Anthropic, local)
│ ├── OpenAIProvider.php
│ └── AnthropicProvider.php
1. Action: Orchestrates the Use Case
// app/AI/Actions/SummarizeTicketAction.php
namespace App\AI\Actions;
use App\AI\DTOs\{SummarizeTicketInput, SummarizeTicketOutput};
use App\AI\Contracts\AIProviderInterface;
use App\AI\Policies\AICostPolicy;
use App\AI\Policies\AIRateLimitPolicy;
use Illuminate\Support\Facades\Log;
class SummarizeTicketAction
{
public function __construct(
private AIProviderInterface $provider,
private AICostPolicy $costPolicy,
private AIRateLimitPolicy $rateLimitPolicy,
) {}
public function handle(SummarizeTicketInput $input): SummarizeTicketOutput
{
// 1. Preventive rate limiting
$this->rateLimitPolicy->check($input->userId, 'summarize');
// 2. Cost estimation before calling
$estimatedTokens = $this->estimateTokens($input->text);
$this->costPolicy->authorize($input->userId, $estimatedTokens, 'summarize');
// 3. Execute with versioned prompt
$prompt = (new SummarizeTicketPrompt())->render($input);
$start = hrtime(true);
$response = $this->provider->complete($prompt, [
'max_tokens' => 500,
'temperature' => 0.3,
'model' => 'gpt-4o-mini', // Configurable via policy
]);
$latencyMs = (hrtime(true) - $start) / 1e6;
// 4. Minimal observability
Log::channel('ai')->info('AI completion', [
'action' => 'summarize_ticket',
'user_id' => $input->userId,
'model' => 'gpt-4o-mini',
'input_tokens' => $response->usage->input_tokens ?? 0,
'output_tokens' => $response->usage->output_tokens ?? 0,
'latency_ms' => $latencyMs,
'cost_usd' => $this->calculateCost($response->usage),
]);
// 5. Validate output contract
$output = SummarizeTicketOutput::fromAIResponse($response->content);
// 6. Record usage for billing/limits
$this->costPolicy->recordUsage($input->userId, $response->usage);
$this->rateLimitPolicy->record($input->userId, 'summarize');
return $output;
}
private function estimateTokens(string $text): int
{
return (int) ceil(strlen($text) / 4); // ~4 chars/token
}
private function calculateCost(object $usage): float
{
// gpt-4o-mini: $0.15/1M input, $0.60/1M output
$inputCost = ($usage->input_tokens ?? 0) * 0.15 / 1_000_000;
$outputCost = ($usage->output_tokens ?? 0) * 0.60 / 1_000_000;
return round($inputCost + $outputCost, 6);
}
}
2. Versioned Prompt (No Inline in Controllers)
// app/AI/Prompts/SummarizeTicketPrompt.php
namespace App\AI\Prompts;
use App\AI\DTOs\SummarizeTicketInput;
class SummarizeTicketPrompt
{
public const VERSION = '1.2.0';
public function render(SummarizeTicketInput $input): string
{
return <<<PROMPT
You are a technical support assistant. Summarize the following ticket in max 3 bullets.
Focus: technical problem, user impact, required action.
Language: professional technical English.
TICKET:
Title: {$input->title}
Description: {$input->description}
User: {$input->userEmail}
Priority: {$input->priority}
Category: {$input->category}
OUTPUT FORMAT (strict JSON):
{
"summary": "bullet 1\nbullet 2\nbullet 3",
"key_entities": ["entity1", "entity2"],
"urgency_score": 1-10,
"suggested_team": "backend|frontend|devops|billing"
}
PROMPT;
}
}
3. DTOs: Typed Contracts (Input/Output)
// app/AI/DTOs/SummarizeTicketInput.php
namespace App\AI\DTOs;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Attributes\Validation\Required;
class SummarizeTicketInput extends Data
{
public function __construct(
#[Required, Max(200)]
public readonly string $title,
#[Required, Max(10000)]
public readonly string $description,
#[Required]
public readonly string $userEmail,
#[Required]
public readonly string $priority, // low|medium|high|critical
#[Required]
public readonly string $category, // technical|billing|account|feature
public readonly int $userId,
) {}
}
// app/AI/DTOs/SummarizeTicketOutput.php
namespace App\AI\DTOs;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Attributes\CastWith;
use App\AI\Casts\JsonArrayCast;
class SummarizeTicketOutput extends Data
{
public function __construct(
public readonly string $summary,
#[CastWith(JsonArrayCast::class)]
public readonly array $keyEntities,
public readonly int $urgencyScore, // 1-10
public readonly string $suggestedTeam, // backend|frontend|devops|billing
public readonly float $confidence, // 0.0-1.0
) {
// Post-construction validation
if ($this->urgencyScore < 1 || $this->urgencyScore > 10) {
throw new \InvalidArgumentException('urgencyScore must be 1-10');
}
if ($this->confidence < 0 || $this->confidence > 1) {
throw new \InvalidArgumentException('confidence must be 0.0-1.0');
}
}
public static function fromAIResponse(string $json): self
{
$data = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException('Invalid AI response JSON: ' . json_last_error_msg());
}
// Strict schema validation
$required = ['summary', 'key_entities', 'urgency_score', 'suggested_team'];
foreach ($required as $field) {
if (!isset($data[$field])) {
throw new \RuntimeException("Missing required field: {$field}");
}
}
return new self(
summary: $data['summary'],
keyEntities: $data['key_entities'],
urgencyScore: (int) $data['urgency_score'],
suggestedTeam: $data['suggested_team'],
confidence: $data['confidence'] ?? 0.9,
);
}
}
4. Policies: Governance (Cost, Rate-Limit, Fallback)
// app/AI/Policies/AICostPolicy.php
namespace App\AI\Policies;
use Illuminate\Support\Facades\Cache;
class AICostPolicy
{
private const DAILY_LIMIT_USD = 50.00; // Per user
private const MONTHLY_LIMIT_USD = 500.00; // Global
public function authorize(int $userId, int $estimatedTokens, string $action): void
{
$estimatedCost = $this->estimateCost($estimatedTokens, $action);
$dailyUsed = $this->getDailyUsed($userId);
if ($dailyUsed + $estimatedCost > self::DAILY_LIMIT_USD) {
throw new \RuntimeException(
"Daily AI cost limit exceeded ({$dailyUsed} + {$estimatedCost} > " . self::DAILY_LIMIT_USD . ")"
);
}
$monthlyUsed = Cache::get('ai:monthly_cost_usd', 0);
if ($monthlyUsed + $estimatedCost > self::MONTHLY_LIMIT_USD) {
throw new \RuntimeException('Monthly AI budget exceeded');
}
}
public function recordUsage(int $userId, object $usage): void
{
$cost = $this->calculateCost($usage);
Cache::increment('ai:daily_cost_' . $userId, $cost * 1_000_000); // Micro-USD
Cache::increment('ai:monthly_cost_usd', $cost * 1_000_000);
// Alert if >80% limit
if ($this->getDailyUsed($userId) > self::DAILY_LIMIT_USD * 0.8) {
// Notify Slack/email
}
}
private function getDailyUsed(int $userId): float
{
return (Cache::get("ai:daily_cost_{$userId}", 0) / 1_000_000);
}
private function estimateCost(int $tokens, string $action): float
{
// gpt-4o-mini pricing
return ($tokens / 1_000_000) * 0.15; // Input only estimate
}
private function calculateCost(object $usage): float
{
$input = ($usage->input_tokens ?? 0) * 0.15 / 1_000_000;
$output = ($usage->output_tokens ?? 0) * 0.60 / 1_000_000;
return $input + $output;
}
}
// app/AI/Policies/AIFallbackPolicy.php
namespace App\AI\Policies;
class AIFallbackPolicy
{
public function getFallback(string $action, \Throwable $exception): array
{
// Graceful degradation per action
return match ($action) {
'summarize' => [
'summary' => 'Summary temporarily unavailable.',
'key_entities' => [],
'urgency_score' => 5,
'suggested_team' => 'backend',
'confidence' => 0.0,
],
'classify' => [
'category' => 'uncategorized',
'confidence' => 0.0,
'routing_team' => 'support',
],
'extract' => [
'data' => [],
'confidence' => 0.0,
],
default => [],
};
}
}
Contract Testing (Not Brittle Mocks)
// tests/Feature/AI/SummarizeTicketTest.php
use App\AI\Actions\SummarizeTicketAction;
use App\AI\DTOs\{SummarizeTicketInput, SummarizeTicketOutput};
use App\AI\Contracts\AIProviderInterface;
use App\AI\Policies\{AICostPolicy, AIRateLimitPolicy};
use Mockery;
test('summarize ticket: happy path returns valid output', function () {
// Arrange
$mockProvider = Mockery::mock(AIProviderInterface::class);
$mockProvider->shouldReceive('complete')
->once()
->andReturn((object)[
'content' => json_encode([
'summary' => "User reports 500 error in checkout\nFails processing Stripe payment\nImpacts premium users",
'key_entities' => ['checkout', 'Stripe', 'payment', 'premium users'],
'urgency_score' => 9,
'suggested_team' => 'backend',
'confidence' => 0.95,
]),
'usage' => (object)[
'input_tokens' => 150,
'output_tokens' => 80,
],
]);
$costPolicy = new AICostPolicy();
$rateLimitPolicy = new AIRateLimitPolicy();
$action = new SummarizeTicketAction($mockProvider, $costPolicy, $rateLimitPolicy);
$input = new SummarizeTicketInput(
title: '500 error in checkout',
description: 'Premium users reporting 500 error when processing Stripe payment...',
userEmail: 'user@example.com',
priority: 'high',
category: 'technical',
userId: 1,
);
// Act
$output = $action->handle($input);
// Assert: Valid output contract
expect($output)->toBeInstanceOf(SummarizeTicketOutput::class)
->and($output->urgencyScore)->toBeBetween(1, 10)
->and($output->confidence)->toBeBetween(0.0, 1.0)
->and($output->suggestedTeam)->toBeIn(['backend', 'frontend', 'devops', 'billing'])
->and($output->keyEntities)->toBeArray();
});
test('summarize ticket: rate limit throws exception', function () {
$mockProvider = Mockery::mock(AIProviderInterface::class);
$costPolicy = Mockery::mock(AICostPolicy::class)->makePartial();
$rateLimitPolicy = Mockery::mock(AIRateLimitPolicy::class);
$rateLimitPolicy->shouldReceive('check')
->andThrow(new \RuntimeException('Rate limit exceeded'));
$action = new SummarizeTicketAction($mockProvider, $costPolicy, $rateLimitPolicy);
$input = new SummarizeTicketInput(/* ... */);
expect(fn() => $action->handle($input))
->toThrow(\RuntimeException::class, 'Rate limit exceeded');
});
test('summarize ticket: invalid JSON response throws', function () {
$mockProvider = Mockery::mock(AIProviderInterface::class);
$mockProvider->shouldReceive('complete')
->andReturn((object)['content' => 'not valid json{{{', 'usage' => new \stdClass()]);
$action = new SummarizeTicketAction($mockProvider, new AICostPolicy(), new AIRateLimitPolicy());
$input = new SummarizeTicketInput(/* ... */);
expect(fn() => $action->handle($input))
->toThrow(\RuntimeException::class, 'Invalid AI response JSON');
});
Async Queues: Fast Response to User
// app/Http/Controllers/AI/TicketSummaryController.php
class TicketSummaryController extends Controller
{
public function store(SummarizeTicketRequest $request): JsonResponse
{
$user = $request->user();
$ticket = $request->validated('ticket_id');
// 1. Create job + respond IMMEDIATELY
SummarizeTicketJob::dispatch($user->id, $ticket)
->onQueue('ai-tasks')
->delay(now()->addSeconds(1));
return response()->json([
'job_id' => $job->getJobId(),
'status' => 'queued',
'poll_url' => route('ai.summary.status', ['job' => $job->getJobId()]),
'estimated_seconds' => 10,
], 202);
}
public function status(string $jobId): JsonResponse
{
$result = Cache::get("ai:job:{$jobId}");
if (!$result) {
return response()->json(['status' => 'processing'], 202);
}
return response()->json([
'status' => 'completed',
'result' => $result,
]);
}
}
// app/Jobs/SummarizeTicketJob.php
class SummarizeTicketJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 2;
public $backoff = [30, 60];
public $timeout = 120;
public function __construct(
public int $userId,
public int $ticketId,
) {}
public function handle(SummarizeTicketAction $action): void
{
try {
$input = SummarizeTicketInput::fromTicket($this->ticketId, $this->userId);
$output = $action->handle($input);
// Save result for polling
Cache::put("ai:job:{$this->jobId()}", $output->toArray(), 3600);
// Notify user (optional: email, push, websocket)
Notification::send($this->user, new TicketSummaryReady($output));
} catch (\Throwable $e) {
Cache::put("ai:job:{$this->jobId()}", [
'error' => $e->getMessage(),
'fallback' => (new AIFallbackPolicy())->getFallback('summarize', $e),
], 3600);
report($e);
throw $e; // Retry via backoff
}
}
}
Minimal Observability (What You Actually Need)
// config/logging.php — Dedicated AI channel
'channels' => [
'ai' => [
'driver' => 'daily',
'path' => storage_path('logs/ai.log'),
'level' => 'info',
'days' => 30,
'permission' => 0644,
],
],
// Structured logs for later analysis
Log::channel('ai')->info('AI completion', [
'action' => 'summarize_ticket',
'user_id' => 123,
'model' => 'gpt-4o-mini',
'input_tokens' => 150,
'output_tokens' => 80,
'latency_ms' => 1200,
'cost_usd' => 0.000067,
'success' => true,
]);
// Sentry alerts for critical failures
Sentry::captureException($e, [
'tags' => ['ai_action' => 'summarize_ticket'],
'level' => 'warning',
]);
Conclusion: AI as Platform Capability
Laravel AI SDK doesn’t replace your architecture — it lets you add AI inside it properly:
| Without SDK (Ad-hoc) | With SDK (Platform) |
|---|---|
| Calls in controllers | Actions + DTOs + Policies |
| Hardcoded prompts | Versioned prompts |
| No cost control | CostPolicy + RateLimitPolicy |
| Brittle mocks in tests | Contracts + swappable providers |
| Sync blocking | Async queues + polling |
| No observability | Structured logs + alerts |
The difference: A feature that impresses in demo vs one that survives real production usage.
Want to Implement This in Your Team?
Available for technical consulting (2-4 weeks) or hiring as Senior who brings this workflow ready:
- AI architecture audit (2 days): Identify gaps, propose prioritized fixes
- Hands-on implementation (1-2 weeks): SDK, Actions, Policies, testing, queues, observability
- Realistic load testing: Simulate your traffic pattern, validate limits, document runbooks
Modalities:
- EOR (Deel, Remote, Oyster) — core indefinite role
- Freelance B2B (Autónomo, intra-community invoice 0% VAT) — 3-12 month projects
- Direct Permanent — if you have Spanish entity
Current Stack: Laravel 11, PHP 8.3+, Vue 3 + TS, Inertia.js, Livewire 3, Astro, Docker, GitHub Actions, Laravel Pulse, Sentry, Claude Code / Cursor / MCP servers.
View My Profile, Full Stack & Terms →
Related Articles on This Blog
- How I Use Claude Code in Production with Laravel and Vue — Morning flow, writing, refactoring, MCP
- How I Do Code Review with Claude Code: Human + AI — Augmented review workflow
- Metrics a Senior Full-Stack Should Defend — Cycle Time, MTTR, Core Web Vitals, infra cost, Bus Factor
- Laravel + Vue for Distributed Teams — Inertia architecture, async workflow, onboarding
