Skip to main content

Laravel AI SDK: Clean Architecture AI Integration for PHP Apps (2026)

Author
Ignacio AmatIgnacio Amat
Published
Reading Time10 min
PHP backend development with Laravel framework and AI SDK integration

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 CaseReal ValueExample Metric
Summarize tickets/long messages-60% support reading time15s → 6s/ticket
Classify leads/formsAuto-routing to right team95% accuracy
Generate content draftsScalable SEO/content marketing10 articles/week → 50
Extract structured dataAutomated reports from free text0 manual entry

With Laravel AI SDK, these flows live as reusable application services with testable boundaries — not experiments in controllers.


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 controllersActions + DTOs + Policies
Hardcoded promptsVersioned prompts
No cost controlCostPolicy + RateLimitPolicy
Brittle mocks in testsContracts + swappable providers
Sync blockingAsync queues + polling
No observabilityStructured 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

Review my developer profile

If this article matches the kind of product work your team is facing, review my stack or professional availability.

Tell me what you need

You can reach out about a role, contract, technical collaboration, question, or general inquiry. A few lines of context are usually enough for me to reply within 24 business hours.

0/500
Availability