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 Uso | Valor Real | Ejemplo Métrica |
|---|---|---|
| Resumir tickets/mensajes largos | -60% tiempo lectura soporte | 15s → 6s/ticket |
| Clasificar leads/formularios | Routing automático a equipo correcto | 95% accuracy |
| Generar borradores contenido | SEO/content marketing escalable | 10 art/semana → 50 |
| Extraer datos estructurados | Reportes automáticos de texto libre | 0 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 controladores | Actions + DTOs + Policies |
| Prompts hardcoded | Prompts versionados |
| Sin control coste | CostPolicy + RateLimitPolicy |
| Mocks frágiles en tests | Contratos + providers swappable |
| Sync bloqueante | Colas async + polling |
| Sin observabilidad | Logs 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
- Cómo uso Claude Code en producción con Laravel y Vue — Flujo matutino, escritura, refactoring, MCP
- Cómo hago Code Review con Claude Code: Humano + IA — Workflow de revisión aumentada
- Métricas que un Senior Full-Stack debe defender — Cycle Time, MTTR, Core Web Vitals, coste infra, Bus Factor
- Laravel + Vue para equipos distribuidos — Arquitectura Inertia, workflow async, onboarding
