Saltar al contenido

Laravel AI SDK (laravel/ai) es el paquete oficial de Laravel para integrar inteligencia artificial en tus aplicaciones. Te da agentes, tools, salida estructurada, conversaciones guardadas en la base de datos, imágenes, audio y embeddings, con la misma API para OpenAI, Anthropic, Gemini, Ollama y el resto de proveedores. Su primera versión salió el 5 de febrero de 2026, la 1.0 estable llegó el 23 de septiembre de 2026 y, en el momento de crear esta entrada, la última versión es la 1.2.0.

En esta entrada vamos a ver cómo se instala y cómo se usa en Laravel 13 con 2 agentes: un clasificador de tickets que devuelve datos estructurados y un asistente de soporte con una tool y memoria. También veremos qué tienes que cambiar si vienes de la 0.x, porque la 1.0 rompe la compatibilidad en las conversaciones, en el middleware y en el consumo de tokens. Todo lo que vas a ver está probado con la 1.2.0 y un modelo local de Ollama, sin claves de ningún proveedor, y las salidas son las reales, con las respuestas que dio ese modelo.

Qué ha cambiado desde la beta

El paquete ha cambiado mucho desde febrero. La 1.0 trae varios cambios incompatibles, que veremos en el apartado de la actualización, y estas son las novedades que más se notan en el día a día:

  • Las conversaciones guardan cada paso del agente en una sola columna, steps, con el resultado de cada tool junto a la llamada que lo produjo.

  • El middleware de los agentes se ejecuta en cada paso, así que puede cambiar el modelo, las tools o los mensajes en mitad de una respuesta.

  • Tools que esperan la aprobación de una persona antes de ejecutarse, ToolSearch para cargar las tools solo cuando hacen falta (en OpenAI, Azure y Anthropic) y ejecución de código en el entorno del proveedor.

  • Soporte de los protocolos de Vercel AI SDK y AG-UI: el SDK lee las peticiones de esas interfaces de chat y les responde en streaming, sin que tengas que escribir el adaptador.

  • El consumo de tokens con los mismos nombres en todos los proveedores, inputTokens y outputTokens.

Después llegaron la 1.0.1, el 29 de septiembre, con Ai::build() para crear un proveedor con la configuración de cada cliente y la solución de un fallo de seguridad; la 1.1.0, el 5 de octubre, con Agent Skills para los agentes de tu aplicación; y la 1.2.0, el 7 de octubre, que actualiza los modelos por defecto de Anthropic. Tienes todos los cambios en las notas de versión y en el anuncio de la 1.0 en el blog de Laravel.

Requisitos

Laravel AI SDK 1.2 necesita PHP 8.3 o superior y Laravel 12 o 13. En concreto, Laravel 12.62 o 13.15 en adelante, porque depende de esas versiones de illuminate/json-schema, el paquete con el que se definen los esquemas de las tools y de la salida estructurada.

En esta entrada trabajamos con un proyecto nuevo de Laravel 13 con Sail, PHP 8.5 y MySQL, y con Ollama como servicio de Sail y el modelo qwen3:4b-instruct, de 2,5 GB, que soporta tools. Si prefieres un proveedor en la nube, solo necesitas su clave en el .env. Vamos a crear el proyecto:

composer create-project laravel/laravel soporte
cd soporte
composer require laravel/sail --dev
php artisan sail:install --with=mysql

Ollama es el servidor que ejecuta los modelos de IA en tu máquina. Para añadirlo a Sail, crea el archivo compose.override.yaml en la raíz del proyecto. Docker Compose lo combina con compose.yaml:

services:
    laravel.test:
        depends_on:
            - ollama
    ollama:
        image: 'ollama/ollama:latest'
        volumes:
            - 'sail-ollama:/root/.ollama'
        networks:
            - sail
volumes:
    sail-ollama:
        driver: local

Levanta Sail y descarga el modelo:

./vendor/bin/sail up -d
./vendor/bin/sail exec ollama ollama pull qwen3:4b-instruct

Si quieres ver con más detalle cómo funciona Ollama en Sail, lo tienes en la lección Laravel AI SDK con Ollama en Laravel Sail del curso Desarrollo seguro con IA en Laravel. El resto de comandos de esta entrada se ejecutan en el contenedor de la aplicación, así que abre una terminal en él:

./vendor/bin/sail shell

Instalar Laravel AI SDK

Instalamos el paquete con Composer, publicamos su configuración y sus migraciones y ejecutamos las migraciones:

composer require laravel/ai
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

En el momento de crear esta entrada, composer require instala la 1.2.0. Esta es la salida de los 2 comandos de Artisan:

   INFO  Publishing assets.  

  Copying file [vendor/laravel/ai/config/ai.php] to [config/ai.php] ..... DONE
  Copying file [vendor/laravel/ai/stubs/agent.stub] to [stubs/agent.stub]  DONE
  Copying file [vendor/laravel/ai/stubs/structured-agent.stub] to [stubs/structured-agent.stub]  DONE
  Copying file [vendor/laravel/ai/stubs/tool.stub] to [stubs/tool.stub] . DONE
  Copying file [vendor/laravel/ai/stubs/agent-middleware.stub] to [stubs/agent-middleware.stub]  DONE
  Copying directory [vendor/laravel/ai/database/migrations] to [database/migrations]  DONE

   INFO  Running migrations.  

  2026_10_12_133512_create_agent_conversations_table ........... 120.49ms DONE

vendor:publish copia 3 cosas: el archivo de configuración, config/ai.php; las plantillas de los comandos make: del paquete, en stubs/, por si quieres cambiar cómo se generan los agentes y las tools; y la migración de las tablas agent_conversations y agent_conversation_messages, donde el SDK guarda las conversaciones.

Configurar el proveedor

Cada proveedor lee su clave de una variable de entorno (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY…), y el proveedor por defecto se elige en la clave default de config/ai.php, que viene con openai. Ollama no necesita clave. Abre config/ai.php y cambia el proveedor por defecto y la entrada de ollama, para indicarle qué modelo tiene que usar:

'default' => 'ollama',

// ...

'ollama' => [
    'driver' => 'ollama',
    'key' => env('OLLAMA_API_KEY', ''),
    'url' => env('OLLAMA_URL', 'http://localhost:11434'),
    'models' => [
        'text' => [
            'default' => env('OLLAMA_MODEL', 'qwen3:4b-instruct'),
        ],
    ],
],

Y en el .env, la URL del servicio de Ollama en la red de Sail y el modelo:

OLLAMA_URL=http://ollama:11434
OLLAMA_MODEL=qwen3:4b-instruct

Si no defines models, el SDK usa los modelos por defecto de cada proveedor, que en la 1.2.0 son qwen3.5:4b en Ollama y Claude Sonnet 5.5 en Anthropic. Esos valores cambian entre versiones del paquete, así que, si necesitas que tu aplicación responda siempre con el mismo modelo, fíjalo en la configuración o en el agente.

Crear un agente con salida estructurada

Un agente es una clase PHP con las instrucciones, las tools y la configuración de una tarea concreta. Vamos a crear un clasificador de tickets de soporte que devuelve la prioridad, el departamento y un resumen, con la opción --structured, que prepara la clase para devolver datos con un esquema:

php artisan make:agent TicketClassifier --structured
   INFO  Agent [app/Ai/Agents/TicketClassifier.php] created successfully.  

El comando genera la clase con todas las interfaces que puede necesitar un agente, también las de conversación y tools. Este no las necesita, así que sustituimos el contenido de app/Ai/Agents/TicketClassifier.php por el siguiente:

<?php

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;

#[Timeout(120)]
class TicketClassifier implements Agent, HasStructuredOutput
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return 'Clasificas tickets de soporte de una tienda online. '
            .'Decide la prioridad y el departamento, y resume el problema en una frase en español.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'priority' => $schema->string()->enum(['low', 'medium', 'high', 'critical'])->required(),
            'department' => $schema->string()->enum(['billing', 'technical', 'sales', 'general'])->required(),
            'summary' => $schema->string()->required(),
        ];
    }
}

Si te fijas, implementa HasStructuredOutput y define su esquema en schema(), con los mismos métodos que usa Laravel para los esquemas JSON: priority y department solo pueden tomar los valores de su enum, y las 3 claves son obligatorias. El atributo #[Timeout(120)] sube a 2 minutos el tiempo máximo de cada petición, que por defecto es de 60 segundos. Con un modelo en local y sin tarjeta gráfica, la primera petición tarda bastante más que las siguientes, porque Ollama tiene que cargar el modelo en memoria.

Hay más atributos para configurar el agente: #[Provider], #[Model], #[MaxSteps], #[MaxTokens], #[Temperature], #[TopP], #[UseCheapestModel] y #[UseSmartestModel]. Si usas Claude, ten en cuenta que desde la 1.2.0 el SDK no envía temperature ni top_p a Claude 4.7 y posteriores, porque esos modelos devuelven un error 400 con un valor distinto del predeterminado. Con ellos, #[Temperature] y #[TopP] no tienen ningún efecto.

Vamos a probarlo desde Tinker:

php artisan tinker

Y en Tinker:

$response = App\Ai\Agents\TicketClassifier::make()->prompt('Me habéis cobrado dos veces el pedido 1043 y necesito que me devolváis uno de los cargos.');
dump($response->toArray());
dump($response['priority']);
dump($response->usage->toArray());
array:3 [
  "department" => "billing"
  "priority" => "high"
  "summary" => "El cliente ha sido cobrado dos veces por el pedido 1043 y pide la devolución de uno de los cargos."
]
"high"
array:5 [
  "input_tokens" => 239
  "output_tokens" => 51
  "cache_read_input_tokens" => 0
  "cache_write_input_tokens" => null
  "reasoning_tokens" => null
]

La respuesta se comporta como un array, así que $response['priority'] te da el valor directamente, y el modelo solo puede devolver las claves y los valores del esquema. Fíjate también en usage: desde la 1.0, los tokens se llaman input_tokens y output_tokens en todos los proveedores, y los de caché y de razonamiento están incluidos en esos totales.

Una tool para consultar pedidos

Las tools son funciones de tu aplicación que el agente puede llamar cuando las necesita. Vamos a dar al asistente de soporte una tool que consulta un pedido del cliente, así que primero necesitamos los pedidos. Sal de Tinker y crea el modelo con su migración:

php artisan make:model Order -m
   INFO  Model [app/Models/Order.php] created successfully.  

   INFO  Migration [database/migrations/2026_10_12_133548_create_orders_table.php] created successfully.  

Abre la migración que se acaba de crear y cambia su contenido por el siguiente, con el cliente, el estado y el importe:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('orders', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained();
            $table->string('status');
            $table->decimal('total', 10, 2);
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::dropIfExists('orders');
    }
};

Y en el modelo, app/Models/Order.php, los campos que se pueden asignar y la relación con el usuario:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[Fillable(['user_id', 'status', 'total'])]
class Order extends Model
{
    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Ahora la tool:

php artisan make:tool OrderStatus
   INFO  Tool [app/Ai/Tools/OrderStatus.php] created successfully.  

Sustituimos el contenido de app/Ai/Tools/OrderStatus.php por el siguiente:

<?php

namespace App\Ai\Tools;

use App\Models\Order;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class OrderStatus implements Tool
{
    public function __construct(private User $user) {}

    public function description(): Stringable|string
    {
        return 'Consulta el estado y el importe de un pedido del cliente a partir de su número.';
    }

    public function handle(Request $request): Stringable|string
    {
        $order = Order::query()
            ->whereBelongsTo($this->user)
            ->find($request['order_id']);

        if ($order === null) {
            return 'Este cliente no tiene ningún pedido con ese número.';
        }

        return json_encode($order->only(['id', 'status', 'total']));
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'order_id' => $schema->integer()->required(),
        ];
    }
}

El modelo decide cuándo llamar a la tool a partir de description(), y schema() le dice qué argumentos tiene que enviar, que aquí es solo el número de pedido. La parte que más cuidado necesita es el constructor: la tool recibe el usuario y solo busca entre sus pedidos, con whereBelongsTo(). El número lo elige el modelo a partir de lo que escribe el cliente, así que, si la tool buscara en todos los pedidos, cualquiera podría consultar los de otro cliente con solo preguntar por su número. Este tipo de fallos en las tools, y cómo evitarlos, los tienes en la lección Tools en Laravel AI SDK: un asistente que consulta tus facturas.

Middleware que se ejecuta en cada paso

Un agente con tools no responde en una sola petición. En el primer paso, el modelo pide llamar a la tool; el SDK la ejecuta y le envía el resultado, y en el segundo paso el modelo escribe la respuesta. Desde la 1.0, el middleware de los agentes envuelve cada uno de esos pasos, y no la respuesta completa como en la 0.x. Vamos a crear uno que escribe en el log cada paso, con su modelo, sus llamadas a tools y sus tokens:

php artisan make:agent-middleware LogSteps
   INFO  Agent Middleware [app/Ai/Middleware/LogSteps.php] created successfully.  

Sustituimos el contenido de app/Ai/Middleware/LogSteps.php por el siguiente:

<?php

namespace App\Ai\Middleware;

use Closure;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Gateway\StepResponse;
use Laravel\Ai\PendingStep;

class LogSteps
{
    public function handle(PendingStep $step, Closure $next)
    {
        return $next($step)->then(function (StepResponse $response) use ($step) {
            Log::info('Paso del agente', [
                'step' => $step->number,
                'model' => $step->model,
                'tool_calls' => count($response->toolCalls),
                'input_tokens' => $response->usage->inputTokens,
                'output_tokens' => $response->usage->outputTokens,
            ]);
        });
    }
}

handle() recibe un PendingStep, con el número de paso, el proveedor, el modelo, los mensajes y las tools que se van a enviar, y tiene que devolver lo que devuelve $next($step). Con then() ejecutamos código cuando el modelo ha respondido al paso, antes de que se ejecuten sus tools. Además de leerlo, el middleware puede cambiar el paso antes de enviarlo, con withModel(), withTools(), withoutTools(), withMessages() o withMaxTokens(), por ejemplo para quitar una tool costosa después del primer paso o para resumir un historial muy largo.

Un agente de soporte que recuerda la conversación

Con la tool y el middleware creados, ya podemos crear el agente:

php artisan make:agent SupportAgent
   INFO  Agent [app/Ai/Agents/SupportAgent.php] created successfully.  

Sustituimos el contenido de app/Ai/Agents/SupportAgent.php por el siguiente:

<?php

namespace App\Ai\Agents;

use App\Ai\Middleware\LogSteps;
use App\Ai\Tools\OrderStatus;
use App\Models\User;
use Laravel\Ai\Attributes\Timeout;
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasMiddleware;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Promptable;
use Stringable;

#[Timeout(120)]
class SupportAgent implements Agent, Conversational, HasMiddleware, HasTools
{
    use Promptable, RemembersConversations;

    public function __construct(public User $user) {}

    public function instructions(): Stringable|string
    {
        return 'Eres el asistente de soporte de una tienda online. Responde en español, en 2 o 3 frases. '
            .'Los importes están en euros. Si el cliente pregunta por un pedido, consúltalo con la herramienta.';
    }

    public function tools(): iterable
    {
        return [
            new OrderStatus($this->user),
        ];
    }

    public function middleware(): array
    {
        return [
            new LogSteps,
        ];
    }
}

El trait RemembersConversations guarda en la base de datos cada mensaje del usuario y cada respuesta, y los carga en el siguiente prompt de la misma conversación. Por eso el agente no define messages(): si lo hiciera, ese método tendría prioridad y el historial no se cargaría. El usuario llega por el constructor, y con él creamos la tool, que solo verá sus pedidos. HasMiddleware, por último, registra LogSteps. Y la frase de los euros no está de adorno: sin ella, en una de las pruebas el modelo respondió que el pedido costaba "59.90 dólares", porque la tool devuelve el importe sin moneda.

El título de cada conversación

Cada vez que empieza una conversación, el SDK hace una petición más al modelo para generar su título, y la hace con el modelo más barato del proveedor. En Ollama, ese modelo es qwen3.5:0.8b, y si no lo tienes descargado, Ollama responde con un 404, el SDK usa el propio mensaje como título y no avisa de nada: el 404 solo aparece en el log de Ollama, no en el de Laravel. Además, como veremos en los tests, esa petición se lleva una de las respuestas falsas de fake(). La opción para desactivarlo no aparece en la documentación ni en el config/ai.php publicado, pero el paquete la lee. Si no necesitas títulos generados, añádela al final del array de config/ai.php:

'conversations' => [
    'generate_title' => false,
],

Con false, el título es el principio del primer mensaje, hasta 50 caracteres. Si prefieres títulos generados, descarga ese modelo o define cheapest en models.text, en la configuración del proveedor.

Para probar el agente, ejecuta las migraciones, que crean la tabla de pedidos:

php artisan migrate

Y desde Tinker, vamos a crear una clienta con 2 pedidos:

$ana = App\Models\User::factory()->create(['name' => 'Ana', 'email' => '[email protected]']);
App\Models\Order::create(['user_id' => $ana->id, 'status' => 'shipped', 'total' => 59.90]);
App\Models\Order::create(['user_id' => $ana->id, 'status' => 'pending', 'total' => 120]);

Y le preguntamos por el primero y, en la misma conversación, por el segundo:

$ana = App\Models\User::firstWhere('email', '[email protected]');
$response = App\Ai\Agents\SupportAgent::make(user: $ana)->forUser($ana)->prompt('Hola, ¿cómo va mi pedido número 1?');
dump($response->text);
$conversation = $response->conversationId;
$response = App\Ai\Agents\SupportAgent::make(user: $ana)->continue($conversation, as: $ana)->prompt('¿Y el otro, el 2?');
dump($response->text);
"Tu pedido número 1 ha sido enviado y tiene un total de 59,90 €. ¿Necesitas algo más?"
"Tu pedido número 2 aún está pendiente y tiene un total de 120,00 €. ¿Quieres que te ayude con algo más?"

El estado y el importe de las 2 respuestas son los que devuelve la tool, y la segunda pregunta ("¿Y el otro, el 2?") se entiende porque el agente envía al modelo los mensajes anteriores de la conversación. Los textos variarán en tu equipo, porque los escribe el modelo, pero los datos salen de tu base de datos.

Sal de Tinker y mira las últimas líneas del log:

tail -n 4 storage/logs/laravel.log

El middleware se ha ejecutado 2 veces por cada pregunta:

[2026-10-12 13:36:01] local.INFO: Paso del agente {"step":0,"model":"qwen3:4b-instruct","tool_calls":1,"input_tokens":224,"output_tokens":21}
[2026-10-12 13:36:05] local.INFO: Paso del agente {"step":1,"model":"qwen3:4b-instruct","tool_calls":0,"input_tokens":275,"output_tokens":30}
[2026-10-12 13:36:09] local.INFO: Paso del agente {"step":0,"model":"qwen3:4b-instruct","tool_calls":1,"input_tokens":323,"output_tokens":21}
[2026-10-12 13:36:16] local.INFO: Paso del agente {"step":1,"model":"qwen3:4b-instruct","tool_calls":0,"input_tokens":374,"output_tokens":35}

En el paso 0, el modelo pide la tool (tool_calls a 1), y en el paso 1 ya tiene el resultado y responde. Fíjate también en que los tokens de entrada de la segunda pregunta son más: el agente envía otra vez el historial de la conversación.

Y esto es lo que guarda la 1.x de la primera respuesta en la columna steps de agent_conversation_messages. Vuelve a abrir Tinker:

php artisan tinker

Y consulta el primer mensaje del asistente:

$message = Laravel\Ai\Models\ConversationMessage::where('role', 'assistant')->oldest()->first();
echo json_encode($message->steps, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), PHP_EOL;
[
    {
        "content": "",
        "tool_calls": [
            {
                "id": "BvwU6aRukMJOJ5FnsKq4qfOta9fsMiK7",
                "name": "OrderStatus",
                "arguments": {
                    "order_id": 1
                },
                "result_id": "BvwU6aRukMJOJ5FnsKq4qfOta9fsMiK7",
                "result": "{\"id\":1,\"status\":\"shipped\",\"total\":\"59.90\"}"
            }
        ],
        "reasoning": "",
        "replay_blocks": [],
        "provider_tool_calls": []
    },
    {
        "content": "Tu pedido número 1 ha sido enviado y tiene un total de 59,90 €. ¿Necesitas algo más?",
        "tool_calls": [],
        "reasoning": "",
        "replay_blocks": [],
        "provider_tool_calls": []
    }
]

Un elemento por cada paso, y la llamada a la tool con su resultado en result. En la 0.x, las llamadas y los resultados se guardaban en 2 columnas, tool_calls y tool_results, y no quedaba guardado en qué paso se había hecho cada llamada. Si consultas estas tablas directamente, es lo que tienes que cambiar al actualizar. El modelo ConversationMessage sigue teniendo los atributos tool_calls y tool_results, pero ahora son de solo lectura y salen de steps.

Claves por cliente con Ai::build()

Si tu aplicación trabaja con la clave de cada cliente, por ejemplo un SaaS en el que cada equipo pone la suya de Anthropic u OpenAI, desde la 1.0.1 tienes Ai::build(). Crea un proveedor a partir de un array con la misma forma que las entradas de config/ai.php, y lo puedes pasar en cualquier sitio donde se acepta un proveedor, también en una lista de failover.

Vamos a probarlo con el clasificador: como primer proveedor, el Ollama de un supuesto equipo 7, que no existe, y como respaldo, el nuestro:

Illuminate\Support\Facades\Event::listen(Laravel\Ai\Events\AgentFailedOver::class, fn ($event) => dump($event->exception->getMessage()));
$response = App\Ai\Agents\TicketClassifier::make()->prompt('No me llega el correo para cambiar la contraseña.', provider: [
    Laravel\Ai\Ai::build(['name' => 'ollama-team-7', 'driver' => 'ollama', 'url' => 'http://ollama-team-7:11434']),
    'ollama',
]);
dump($response->meta->provider, $response->toArray());
"Could not connect to AI provider [ollama-team-7]."
"ollama"
array:3 [
  "department" => "general"
  "priority" => "medium"
  "summary" => "El cliente no recibe el correo para cambiar su contraseña."
]

name es opcional y sirve para reconocer el proveedor en los errores y en los eventos. Si se lo das, no puede coincidir con uno de config/ai.php ni con uno de los que trae el SDK. El primer proveedor no responde, el SDK lanza una ProviderConnectionException, que dispara el evento AgentFailedOver, y pasa al siguiente.

Ahora bien, el failover solo salta con errores de conexión, límites de uso, sobrecarga del proveedor o falta de crédito, y una clave incorrecta no es ninguno de ellos. Vamos a repetirlo con una clave de Anthropic que no es válida:

try {
    App\Ai\Agents\TicketClassifier::make()->prompt('No me llega el correo para cambiar la contraseña.', provider: [
        Laravel\Ai\Ai::build(['name' => 'anthropic-team-7', 'driver' => 'anthropic', 'key' => 'sk-ant-clave-de-prueba']),
        'ollama',
    ]);
} catch (Throwable $e) {
    echo get_class($e), PHP_EOL, Illuminate\Support\Str::limit($e->getMessage(), 140), PHP_EOL;
}
Illuminate\Http\Client\RequestException
HTTP request returned status code 401:
{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"},"request_id":"r...

Anthropic responde con un 401, el SDK lanza una RequestException y no pasa a Ollama. Si las claves las ponen tus clientes, comprueba que funcionan al guardarlas y captura este error donde llames al agente, porque el failover no te va a cubrir.

Tests con fakes

Cada agente tiene su método fake(), igual que Http::fake() o Mail::fake(). Vamos a usarlo para probar un endpoint que continúa la conversación de soporte, y de paso, a cubrir un aviso de la documentación: continue() no comprueba que la conversación sea del usuario que le pasas. Si el id de la conversación llega del frontend, lo tienes que comprobar tú. Sal de Tinker y vamos a empezar por el controlador:

php artisan make:controller SupportMessageController --invokable
   INFO  Controller [app/Http/Controllers/SupportMessageController.php] created successfully.  

En app/Http/Controllers/SupportMessageController.php:

<?php

namespace App\Http\Controllers;

use App\Ai\Agents\SupportAgent;
use Illuminate\Http\Request;
use Laravel\Ai\Contracts\ConversationStore;

class SupportMessageController extends Controller
{
    public function __invoke(Request $request, string $conversation, ConversationStore $store)
    {
        $user = $request->user();

        abort_unless($store->conversationBelongsTo($conversation, $user->getMorphClass(), $user->getKey()), 403);

        $validated = $request->validate([
            'message' => ['required', 'string', 'max:2000'],
        ]);

        $response = SupportAgent::make(user: $user)
            ->continue($conversation, as: $user)
            ->prompt($validated['message']);

        return ['reply' => $response->text];
    }
}

conversationBelongsTo() es del almacén de conversaciones del SDK y comprueba que la conversación se guardó para ese usuario. Si no es suya, la petición termina con un 403 antes de llegar al modelo. La ruta, en routes/web.php:

use App\Http\Controllers\SupportMessageController;

Route::post('/support/{conversation}', SupportMessageController::class)
    ->middleware('auth');

Los tests de esta entrada usan Pest, y un proyecto nuevo de Laravel trae PHPUnit. Vamos a cambiarlo por Pest:

composer remove phpunit/phpunit --dev
composer require pestphp/pest pestphp/pest-plugin-laravel --dev -W
./vendor/bin/pest --init

Los tests usan la base de datos, así que tenemos que activar RefreshDatabase. pest --init crea tests/Pest.php con la línea // ->use(RefreshDatabase::class) comentada. Abre el archivo y quita el comentario, para que el principio quede así:

<?php

use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

// ...

pest()->extend(TestCase::class)
    ->use(RefreshDatabase::class)
    ->in('Feature');

Y ahora el test:

php artisan make:test SupportMessageTest --pest
   INFO  Test [tests/Feature/SupportMessageTest.php] created successfully.  

En tests/Feature/SupportMessageTest.php:

<?php

use App\Ai\Agents\SupportAgent;
use App\Models\User;

it('continues the conversation of its owner', function () {
    SupportAgent::fake(['Hola, Ana.', 'Tu pedido 2 está pendiente.']);

    $ana = User::factory()->create();
    $conversation = SupportAgent::make(user: $ana)->forUser($ana)->prompt('Hola')->conversationId;

    test()->actingAs($ana)
        ->post("/support/{$conversation}", ['message' => '¿Cómo va el pedido 2?'])
        ->assertOk()
        ->assertExactJson(['reply' => 'Tu pedido 2 está pendiente.']);

    SupportAgent::assertPrompted('¿Cómo va el pedido 2?');
});

it('does not let another user continue the conversation', function () {
    SupportAgent::fake(['Hola, Ana.']);

    $ana = User::factory()->create();
    $conversation = SupportAgent::make(user: $ana)->forUser($ana)->prompt('Hola')->conversationId;

    test()->actingAs(User::factory()->create())
        ->post("/support/{$conversation}", ['message' => 'Enséñame sus pedidos'])
        ->assertForbidden();

    SupportAgent::assertNotPrompted('Enséñame sus pedidos');
});

SupportAgent::fake() recibe las respuestas en orden: la primera es para el prompt que crea la conversación y la segunda, para el del endpoint. Con la generación de títulos activada, la segunda respuesta se la lleva el título de la conversación, y el primer test falla porque el endpoint devuelve el texto genérico del fake, Fake response for prompt: ….

php artisan test tests/Feature/SupportMessageTest.php
   PASS  Tests\Feature\SupportMessageTest
  ✓ it continues the conversation of its owner                           1.25s  
  ✓ it does not let another user continue the conversation               0.07s  

  Tests:    2 passed (5 assertions)
  Duration: 1.40s

Actualizar desde la 0.x

La 1.0 trae cambios incompatibles, y la guía de actualización los ordena según la probabilidad de que te afecten. Laravel Boost 2.10 trae además un prompt, /upgrade-ai-sdk-v1, que guía a tu asistente de IA por los cambios uno a uno, pero aquí vamos a hacerlo a mano para ver cada error.

Para ver lo que pasa en un proyecto, vamos a partir de uno con la 0.11.2, la última 0.x. Los 2 proyectos usan los mismos puertos, así que, si el primero sigue levantado, páralo con este comando en su carpeta, soporte:

./vendor/bin/sail down

Y crea el proyecto nuevo junto a él, en la carpeta soporte-0x, con los mismos comandos del principio:

cd ..
composer create-project laravel/laravel soporte-0x
cd soporte-0x
composer require laravel/sail --dev
php artisan sail:install --with=mysql

Docker Compose crea los volúmenes con el nombre de la carpeta delante, así que el modelo que descargamos al principio está en el volumen soporte_sail-ollama, y con el compose.override.yaml del principio este proyecto tendría su propio volumen vacío y habría que repetir el pull. Para usar el que ya tienes, crea compose.override.yaml con ese volumen como externo:

services:
    laravel.test:
        depends_on:
            - ollama
    ollama:
        image: 'ollama/ollama:latest'
        volumes:
            - 'sail-ollama:/root/.ollama'
        networks:
            - sail
volumes:
    sail-ollama:
        name: soporte_sail-ollama
        external: true

Levanta Sail, sin descargar nada, y abre una terminal en el contenedor:

./vendor/bin/sail up -d
./vendor/bin/sail shell

En el proyecto nuevo, instala la 0.11.2 en lugar de la 1.x, publica la configuración y ejecuta las migraciones:

composer require laravel/ai:0.11.2
php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
php artisan migrate

Después, haz en config/ai.php los mismos cambios de antes: los 2 del apartado "Configurar el proveedor" (el proveedor por defecto y el modelo en la entrada de ollama) y la clave conversations al final del array, la del título de las conversaciones. Añade al .env las mismas 2 variables de Ollama:

OLLAMA_URL=http://ollama:11434
OLLAMA_MODEL=qwen3:4b-instruct

Los archivos del agente de soporte son los mismos: la migración y el modelo de los pedidos, la tool OrderStatus y SupportAgent. El contenedor solo ve la carpeta de su proyecto, así que cópialos desde otra terminal de tu máquina, en la carpeta soporte-0x:

mkdir -p app/Ai/Tools app/Ai/Agents app/Ai/Middleware
cp ../soporte/database/migrations/*_create_orders_table.php database/migrations/
cp ../soporte/app/Models/Order.php app/Models/
cp ../soporte/app/Ai/Tools/OrderStatus.php app/Ai/Tools/
cp ../soporte/app/Ai/Agents/SupportAgent.php app/Ai/Agents/

Lo único que cambia es el middleware, que en la 0.x se escribía así. Créalo en app/Ai/Middleware/LogPrompts.php:

<?php

namespace App\Ai\Middleware;

use Closure;
use Illuminate\Support\Facades\Log;
use Laravel\Ai\Prompts\AgentPrompt;
use Laravel\Ai\Responses\AgentResponse;

class LogPrompts
{
    public function handle(AgentPrompt $prompt, Closure $next)
    {
        return $next($prompt)->then(function (AgentResponse $response) {
            Log::info('Respuesta del agente', [
                'prompt_tokens' => $response->usage->promptTokens,
                'completion_tokens' => $response->usage->completionTokens,
            ]);
        });
    }
}

Y en app/Ai/Agents/SupportAgent.php, cambia LogSteps por LogPrompts en las 2 líneas donde aparece, el use y middleware(). Con todo creado, ejecuta las migraciones:

php artisan migrate

Vamos a crear la clienta con un pedido y a hacerle una pregunta al agente desde Tinker, para que la 0.11.2 guarde una conversación con una llamada a la tool:

$ana = App\Models\User::factory()->create(['name' => 'Ana', 'email' => '[email protected]']);
App\Models\Order::create(['user_id' => $ana->id, 'status' => 'shipped', 'total' => 59.90]);
$response = App\Ai\Agents\SupportAgent::make(user: $ana)->forUser($ana)->prompt('¿Cómo va mi pedido número 1?');
dump($response->text);
dump((array) DB::table('agent_conversation_messages')->where('role', 'assistant')->first(['tool_calls', 'tool_results', 'approval_state']));

Con la 0.11.2, la conversación guarda la llamada y el resultado en 2 columnas:

array:3 [
  "tool_calls" => "[{"id":"VVgYYKEgP3tKbwpd3gRFfM0dJvqj5Q3p","name":"OrderStatus","arguments":{"order_id":1},"result_id":"VVgYYKEgP3tKbwpd3gRFfM0dJvqj5Q3p","reasoning_id":null,"reasoning_summary":null,"reasoning_encrypted_content":null}]"
  "tool_results" => "[{"id":"VVgYYKEgP3tKbwpd3gRFfM0dJvqj5Q3p","name":"OrderStatus","arguments":{"order_id":1},"result":"{\"id\":1,\"status\":\"shipped\",\"total\":\"59.90\"}","result_id":"VVgYYKEgP3tKbwpd3gRFfM0dJvqj5Q3p"}]"
  "approval_state" => null
]

Actualizar el paquete

Sal de Tinker y actualiza el paquete con -W, para que Composer pueda actualizar también las dependencias que lo necesiten:

composer require "laravel/ai:^1.2" -W
  - Removing aws/aws-crt-php (v1.2.7)
  - Removing aws/aws-sdk-php (3.400.1)
  - Upgrading laravel/ai (v0.11.2 => v1.2.0)
  - Upgrading laravel/ai (v0.11.2 => v1.2.0): Extracting archive
No security vulnerability advisories found.

Fíjate en que Composer desinstala el SDK de AWS: desde la 1.0 ya no viene con el paquete, así que, si usas Bedrock, tienes que instalarlo tú:

composer require aws/aws-sdk-php

Y si tu proyecto usa laravel/mcp, tiene que estar en la 1.0 o superior, porque la 1.x del SDK no es compatible con las anteriores.

El middleware

Vamos a repetir la pregunta en Tinker, ya con la 1.x:

try {
    $ana = App\Models\User::firstWhere('email', '[email protected]');
    $response = App\Ai\Agents\SupportAgent::make(user: $ana)->forUser($ana)->prompt('¿Cómo va mi pedido número 1?');
    dump($response->text);
} catch (Throwable $e) {
    dump(get_class($e), $e->getMessage());
}

El primer error salta en cuanto el agente da el primer paso, por el middleware escrito para la 0.x:

"TypeError"
"App\Ai\Middleware\LogPrompts::handle(): Argument #1 ($prompt) must be of type Laravel\Ai\Prompts\AgentPrompt, Laravel\Ai\PendingStep given, called in …/vendor/laravel/ai/src/Gateway/TextGenerationLoop.php on line 450"

La solución es la firma nueva, la de LogSteps que hemos visto antes: handle() recibe un PendingStep, y then(), un StepResponse. Borra app/Ai/Middleware/LogPrompts.php, crea app/Ai/Middleware/LogSteps.php con el código de LogSteps y vuelve a poner LogSteps en el agente. Ten en cuenta que ahora se ejecuta una vez por paso, así que, si sumabas tokens o contabas llamadas, con tools se ejecutará varias veces por prompt. Y que promptTokens y completionTokens ya no existen: ahora son inputTokens y outputTokens, e incluyen los tokens de caché y de razonamiento. Si calculas costes con ellos, revisa la guía, porque cada tipo de token tiene su propia tarifa.

La migración de las conversaciones

Con el middleware corregido, si repites la pregunta en una sesión nueva de Tinker, el prompt falla al guardar el mensaje:

Illuminate\Database\QueryException {}
"SQLSTATE[42S22]: Column not found: 1054 Unknown column 'steps' in 'field list' (Connection: mysql, …)"

La migración del paquete ya se ejecutó en su día y no se vuelve a ejecutar, así que hay que crear una nueva. Sal de Tinker y créala:

php artisan make:migration upgrade_agent_conversation_messages_to_steps
   INFO  Migration [database/migrations/2026_10_12_133739_upgrade_agent_conversation_messages_to_steps.php] created successfully.  

Cambia su contenido por la migración "Backfill migration" de la guía, tal cual:

<?php

use Illuminate\Database\Query\Builder;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Laravel\Ai\Enums\MessageStatus;
use Laravel\Ai\Migrations\AiMigration;

return new class extends AiMigration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        $table = config('ai.conversations.tables.messages', 'agent_conversation_messages');

        Schema::connection($this->getConnection())->table($table, function (Blueprint $blueprint) {
            $blueprint->longText('steps')->nullable();
            $blueprint->string('status', 25)->default(MessageStatus::Completed->value);
        });

        $this->query($table)->where('role', 'user')->update(['steps' => '[]']);

        $this->query($table)
            ->select('conversation_id')
            ->distinct()
            ->orderBy('conversation_id')
            ->chunk(100, function (Collection $conversations) use ($table) {
                foreach ($conversations as $conversation) {
                    $this->backfill($table, $conversation->conversation_id);
                }
            });

        Schema::connection($this->getConnection())->table($table, function (Blueprint $blueprint) {
            $blueprint->longText('steps')->nullable(false)->change();
            $blueprint->dropColumn(['tool_calls', 'tool_results', 'approval_state']);
            $blueprint->dropIndex('participant_index');
            $blueprint->index(['participant_type', 'participant_id', 'agent'], 'participant_index');
        });
    }

    /**
     * Rewrite one conversation's assistant rows as steps, each result landing on the call that made it.
     */
    protected function backfill(string $table, string $conversationId): void
    {
        $rows = $this->query($table)
            ->where('conversation_id', $conversationId)
            ->where('role', 'assistant')
            ->orderBy('id')
            ->get();

        // A result was recorded on the row of the request that produced it, which may be a later row than its call...
        $results = $rows->flatMap(fn (object $row) => $this->decoded($row->tool_results))->keyBy('id');

        foreach ($rows as $row) {
            $meta = $this->decoded($row->meta);

            $calls = collect($this->decoded($row->tool_calls))
                ->filter(fn (array $call) => $results->has($call['id'] ?? ''))
                ->map(fn (array $call) => [
                    ...$call,
                    'result' => $results[$call['id']]['result'] ?? null,
                    ...array_filter([
                        'denied' => $results[$call['id']]['denied'] ?? false,
                        'failed' => $results[$call['id']]['failed'] ?? false,
                    ]),
                ])
                ->values()
                ->all();

            $content = (string) $row->content;

            $steps = $calls !== [] && $content !== ''
                ? [$this->step('', $calls), $this->step($content, [], $meta['reasoning'] ?? '')]
                : [$this->step($content, $calls, $meta['reasoning'] ?? '')];

            unset($meta['provider_steps'], $meta['provider_content_blocks'], $meta['reasoning']);

            $this->query($table)->where('id', $row->id)->update([
                'steps' => json_encode($steps),
                'meta' => json_encode($meta),
            ]);
        }
    }

    /**
     * Build a conversation step from its content, tool calls, and reasoning.
     *
     * @param  list<array<string, mixed>>  $calls
     * @return array<string, mixed>
     */
    protected function step(string $content, array $calls = [], string $reasoning = ''): array
    {
        return [
            'content' => $content,
            'tool_calls' => $calls,
            'reasoning' => $reasoning,
            'replay_blocks' => [],
            'provider_tool_calls' => [],
        ];
    }

    /**
     * Decode a JSON value into an array, returning an empty array for invalid or empty input.
     *
     * @return array<string, mixed>
     */
    protected function decoded(?string $json): array
    {
        return is_array($decoded = json_decode($json ?? '', true)) ? $decoded : [];
    }

    /**
     * Create a query builder for the conversation messages table.
     */
    protected function query(string $table): Builder
    {
        return DB::connection($this->getConnection())->table($table);
    }
};

La migración añade las columnas steps y status, pasa cada mensaje guardado al formato nuevo, borra tool_calls, tool_results y approval_state y añade el agente al índice participant_index. Antes de ejecutarla, termina o descarta las conversaciones que estén esperando la aprobación de una tool, porque después ya no se pueden reanudar.

php artisan migrate
   INFO  Running migrations.  

  2026_10_12_133739_upgrade_agent_conversation_messages_to_steps  225.43ms DONE

Si repites en Tinker la consulta de la columna steps que hemos visto antes, la llamada que la 0.11.2 había guardado en 2 columnas queda así, con su resultado y con los campos que guardaba la 0.x:

[
    {
        "content": "",
        "tool_calls": [
            {
                "id": "VVgYYKEgP3tKbwpd3gRFfM0dJvqj5Q3p",
                "name": "OrderStatus",
                "arguments": {
                    "order_id": 1
                },
                "result_id": "VVgYYKEgP3tKbwpd3gRFfM0dJvqj5Q3p",
                "reasoning_id": null,
                "reasoning_summary": null,
                "reasoning_encrypted_content": null,
                "result": "{\"id\":1,\"status\":\"shipped\",\"total\":\"59.90\"}"
            }
        ],
        "reasoning": "",
        "replay_blocks": [],
        "provider_tool_calls": []
    },
    …
]

Y la conversación sigue funcionando con la 1.2.0. El agente carga el historial migrado (el mensaje del usuario, la llamada a la tool, su resultado y la respuesta) y responde a una pregunta que depende de él:

$ana = App\Models\User::firstWhere('email', '[email protected]');
$conversation = Laravel\Ai\Models\Conversation::oldest()->value('id');
$agent = App\Ai\Agents\SupportAgent::make(user: $ana)->continue($conversation, as: $ana);
foreach ($agent->messages() as $message) { dump($message->role->value.': '.Illuminate\Support\Str::limit((string) $message->content, 60)); }
dump($agent->prompt('¿Y cuánto costaba ese pedido?')->text);
"user: ¿Cómo va mi pedido número 1?"
"assistant: "
"tool_result: "
"assistant: Tu pedido número 1 ha sido enviado y tiene un total de 59,90..."
"El pedido número 1 costaba 59,90 €."

El aviso de seguridad de la 1.0.0

El 30 de septiembre de 2026 se publicó el aviso GHSA-6qhr-3g93-pxhw, de gravedad media. Los adaptadores de Vercel AI SDK y de AG-UI de la 1.0.0 aceptaban archivos con una URL enviada por el cliente y la descargaban desde el servidor sin validarla. Es un SSRF (falsificación de peticiones del lado del servidor): cualquiera que pudiera llamar a tu endpoint de chat podía hacer que tu servidor pidiera direcciones internas, como el servicio de metadatos de la nube o tu red privada, y lo que devolvían esas direcciones le llegaba al modelo como archivo adjunto, así que podía acabar en su respuesta.

Solo afecta a la 1.0.0, porque esos adaptadores no existían en la 0.x, y solo si expones uno de ellos a usuarios que no son de confianza. Está corregido en la 1.0.1, que valida cada URL y cada redirección antes de descargarla.

Eso sí, en el momento de crear esta entrada, Composer no lo conoce. Sal de Tinker y vamos a instalar la 1.0.0 y a auditar las dependencias:

composer require laravel/ai:1.0.0
composer audit
  - Downgrading laravel/ai (v1.2.0 => v1.0.0)
  - Downgrading laravel/ai (v1.2.0 => v1.0.0): Extracting archive
No security vulnerability advisories found.
No security vulnerability advisories found.

Composer instala la 1.0.0 sin avisar de nada, y composer audit tampoco encuentra ningún aviso. El aviso está publicado en el repositorio de laravel/ai, pero todavía no en la base de datos de avisos de GitHub ni en la de Packagist, que es la que consulta Composer. Comprueba tu versión con este comando y, si tienes la 1.0.0, actualiza:

composer show laravel/ai

Cómo llegan los avisos desde el repositorio de cada paquete hasta Composer y Dependabot lo tienes en la lección Avisos de seguridad de Laravel: enterarte a tiempo con composer audit y Dependabot.

Espero que te sirva. Si quieres seguir aprendiendo a usar Laravel AI SDK sin abrir agujeros de seguridad en tu aplicación, no te pierdas el curso Desarrollo seguro con IA en Laravel.

school Curso completo

Curso Laravel 12
Completo 2026

El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.

access_time 8 horas de contenido
layers 4 tecnologías en 1
update 100% actualizado
code Proyectos prácticos
Ver Curso Laravel 12 arrow_forward

star Incluido en cualquier suscripción

Rutas de aprendizaje