Saltar al contenido principal

Laravel AI SDK: Integración de IA con arquitectura limpia en apps PHP (2026)

Autor
Ignacio AmatIgnacio Amat
Publicado
Lectura10 min
Desarrollo backend PHP con Laravel e integración del SDK de IA

Laravel presentó oficialmente su AI SDK, y para quienes trabajamos con PHP en producción esto cambia el panorama: no es solo “agregar IA”, es integrar modelos con estructura Laravel-friendly, código predecible y menos glue logic ad-hoc.

Puedes encapsular prompts, proveedores y lógica de decisión en una capa mantenible en lugar de dispersar llamadas API en controladores o jobs. Aquí te muestro cómo lo uso en producción con código real, tests, y gobernanza técnica.


Por Qué Importa en Productos Reales

En proyectos portfolio y SaaS, los casos de uso IA de mayor valor son operacionales, no conversacionales:

Caso de UsoValor RealEjemplo Métrica
Resumir tickets/mensajes largos-60% tiempo lectura soporte15s → 6s/ticket
Clasificar leads/formulariosRouting automático a equipo correcto95% accuracy
Generar borradores contenidoSEO/content marketing escalable10 art/semana → 50
Extraer datos estructuradosReportes automáticos de texto libre0 manual entry

Con Laravel AI SDK, estos flujos viven como servicios de aplicación reutilizables y testeables, no como experimentos en controladores.


Arquitectura Recomendada: Separación de Responsabilidades

app/
├── AI/
│   ├── Actions/          # Un Action por caso de uso
│   │   ├── SummarizeTicketAction.php
│   │   ├── ClassifyLeadAction.php
│   │   └── ExtractInvoiceDataAction.php
│   ├── Prompts/          # Plantillas versionadas
│   │   ├── SummarizeTicketPrompt.php
│   │   └── ClassifyLeadPrompt.php
│   ├── DTOs/             # Contratos tipados entrada/salida
│   │   ├── SummarizeTicketInput.php
│   │   ├── SummarizeTicketOutput.php
│   │   └── ClassifyLeadResult.php
│   ├── Policies/         # Gobernanza: coste, rate-limit, fallback
│   │   ├── AICostPolicy.php
│   │   ├── AIRateLimitPolicy.php
│   │   └── AIFallbackPolicy.php
│   ├── Contracts/        # Interfaces para testabilidad
│   │   └── AIProviderInterface.php
│   └── Providers/        # Implementaciones (OpenAI, Anthropic, local)
│       ├── OpenAIProvider.php
│       └── AnthropicProvider.php

1. Action: Orquestación del Caso de Uso

// 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. Rate limiting preventivo
        $this->rateLimitPolicy->check($input->userId, 'summarize');
        
        // 2. Estimación coste antes de llamar
        $estimatedTokens = $this->estimateTokens($input->text);
        $this->costPolicy->authorize($input->userId, $estimatedTokens, 'summarize');
        
        // 3. Ejecuta con prompt versionado
        $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. Observabilidad mínima
        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. Valida contrato salida
        $output = SummarizeTicketOutput::fromAIResponse($response->content);
        
        // 6. Registra uso para facturación/limites
        $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); // Aprox 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. Prompt Versionado (No Inline en Controladores)

// 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
Eres un asistente de soporte técnico. Resume el siguiente ticket en máximo 3 bullets.
Enfoque: problema técnico, impacto usuario, acción requerida.
Idioma: español técnico profesional.

TICKET:
Título: {$input->title}
Descripción: {$input->description}
Usuario: {$input->userEmail}
Prioridad: {$input->priority}
Categoría: {$input->category}

FORMATO SALIDA (JSON estricto):
{
  "summary": "bullet 1\nbullet 2\nbullet 3",
  "key_entities": ["entidad1", "entidad2"],
  "urgency_score": 1-10,
  "suggested_team": "backend|frontend|devops|billing"
}
PROMPT;
    }
}

3. DTOs: Contratos Tipados (Entrada/Salida)

// 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
    ) {
        // Validación post-constructor
        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());
        }
        
        // Validación esquemas estricta
        $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: Gobernanza (Coste, 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; // Por usuario
    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);
        
        // Alerta si >80% límite
        if ($this->getDailyUsed($userId) > self::DAILY_LIMIT_USD * 0.8) {
            // Notifica a 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
    {
        // Degradación grácil según acción
        return match ($action) {
            'summarize' => [
                'summary' => 'Resumen no disponible temporalmente.',
                '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 => [],
        };
    }
}

Testing con Contratos (No Mocks Frágiles)

// 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' => "Usuario reporta error 500 en checkout\nFalla al procesar pago Stripe\nImpacta a usuarios premium",
                'key_entities' => ['checkout', 'Stripe', 'pago', 'usuarios premium'],
                '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: 'Error 500 en checkout',
        description: 'Usuarios premium reportan error 500 al procesar pago con Stripe...',
        userEmail: 'user@example.com',
        priority: 'high',
        category: 'technical',
        userId: 1,
    );
    
    // Act
    $output = $action->handle($input);
    
    // Assert: Contrato de salida válido
    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');
});

Colas Async: Respuesta Rápida al Usuario

// 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. Crea job + responde INMEDIATAMENTE
        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);
            
            // Guarda resultado para polling
            Cache::put("ai:job:{$this->jobId()}", $output->toArray(), 3600);
            
            // Notifica usuario (opcional: 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; // Reintento via backoff
        }
    }
}

Observabilidad Mínima (Lo Que Realmente Necesitas)

// config/logging.php — Canal dedicado IA
'channels' => [
    'ai' => [
        'driver' => 'daily',
        'path' => storage_path('logs/ai.log'),
        'level' => 'info',
        'days' => 30,
        'permission' => 0644,
    ],
],

// Logs estructurados para análisis posterior
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,
]);

// Alertas Sentry para fallos críticos
Sentry::captureException($e, [
    'tags' => ['ai_action' => 'summarize_ticket'],
    'level' => 'warning',
]);

Conclusión: IA Como Capacidad de Plataforma

Laravel AI SDK no reemplaza tu arquitectura — te permite agregar IA dentro de ella de forma seria:

Sin SDK (Ad-hoc)Con SDK (Plataforma)
Llamadas en controladoresActions + DTOs + Policies
Prompts hardcodedPrompts versionados
Sin control costeCostPolicy + RateLimitPolicy
Mocks frágiles en testsContratos + providers swappable
Sync bloqueanteColas async + polling
Sin observabilidadLogs estructurados + alertas

La diferencia: Una feature que impresiona en demo vs una que sobrevive uso real en producción.


¿Quieres Implementar Esto en Tu Equipo?

Disponible para consultoría técnica (2-4 semanas) o incorporación como Senior que trae este workflow listo:

  • Auditoría arquitectura IA (2 días): Identifico gaps, propongo fixes priorizados
  • Implementación hands-on (1-2 semanas): SDK, Actions, Policies, testing, colas, observabilidad
  • Load testing realista: Simulo tu patrón de tráfico, valido límites, documento runbooks

Modalidades:

  • EOR (Deel, Remote, Oyster) — rol core indefinido
  • Freelance B2B (Autónomo, factura intracomunitaria 0% IVA) — proyectos 3-12 meses
  • Indefinido directo — si tenéis entidad en España

Stack actual: Laravel 11, PHP 8.3+, Vue 3 + TS, Inertia.js, Livewire 3, Astro, Docker, GitHub Actions, Laravel Pulse, Sentry, Claude Code / Cursor / MCP servers.

Ver mi perfil, stack completo y condiciones →


Artículos Relacionados en Este Blog

Artículos relacionados

Revisa mi perfil como desarrollador

Si este artículo encaja con los retos técnicos de tu equipo, revisa mi stack o mi disponibilidad profesional.

Cuéntame qué necesitas

Puedes escribirme por un rol, contrato, colaboración técnica, una duda o una consulta general. Con 2-3 líneas de contexto suelo poder responder en menos de 24 horas hábiles.

0/500
Disponibilidad