Build applications with Laravel Agents SDK AI

Laravel AI SDK. This tool is not meant to help you code, but to make your application intelligent.

If you want your user to be able to:

  • Generate an image of a "space donut" from a form.
  • Automatically translate a comment.
  • Interact with a chatbot that uses local or external models.

Then you use the SDK. It's a library that lets you fire prompts directly from your PHP code. Unlike Boost, this is a regular dependency of the application, as it will be part of the features your end users will enjoy.

Previously we saw how to use Laravel Boost for coding.

Installation and Configuration of the Laravel AI SDK

Unlike Laravel Boost, the Laravel AI SDK is what allows us to get our hands dirty by creating our own prompts and artificial intelligence logic within the app.

First steps:

  1. Installation: Run the installation command:
    1. $ composer require laravel/ai
    2. $ php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"
    3. $ php artisan migrate
  2. Database: You will see that migrations are created for conversations and agents tables. It is essential to run php artisan migrate so that the SDK has a place to save the history and configuration.
  3. API Key: In your .env file, you must configure your key.
    1. ANTHROPIC_API_KEY=
      COHERE_API_KEY=
      ELEVENLABS_API_KEY=
      GEMINI_API_KEY=
      MISTRAL_API_KEY=
      OLLAMA_API_KEY=
      OPENAI_API_KEY=
      JINA_API_KEY=
      VOYAGEAI_API_KEY=
      XAI_API_KEY=

If you are looking for a free option to start, Google AI Studio is one of the best. You just have to go in, generate your Gemini API Key and paste it into your project.

The Heart of the SDK: The Agents

The big difference here is that everything works through Agents.

  • An Agent is like a "virtual employee" to whom you assign a specific task (e.g.: "You are a translator", "You are an SEO expert", "You are an image generator").
  • Once the agent is configured, you interact with it through prompts.
use Laravel\Ai\Enums\Lab;
use function Laravel\Ai\agent;
Route::get('/laravel-ai-text', function () {
    $response = agent(
        instructions: 'You are an expert assistant in Laravel.',)->prompt(
        'Generate a list of 3 Laravel 13 topics in JSON format',
        provider: Lab::Gemini,
        model: <YOURIDLOCALMODAL>);
    
    dd($response);
});

What we saw is an anonymous agent, since the recommended structure is through agents, which we will see later.

Google Antigravity: Power without Complications

We have already overcome the learning curve of Laravel Boost and OpenCode, so Google Antigravity will seem like a walk in the park. It is simpler because it is less abstract: it is an IDE that already has AI in its "DNA".

The big difference: Integration vs. Personalization

We must be clear here: they don't "match".

  • OpenCode (VS Code): It is an open environment. You install the agents, configure the Markdown files and have surgical control.
  • Anti Gravity: It is a closed environment. It is more limited in terms of external rules (you can't directly copy and paste the OpenCode guides here), but in return it offers top-tier models (Gemini 3 Pro, Claude, Sonnet) for free in its current plan.

Configuration and Rules (Custom Instructions)

Although it is more closed, it doesn't mean you can't give it orders. In the three points of the interface, you can customize the rules, either globally or for a specific workspace.

  • When you save these rules, a gemini.md file is generated (very similar to what we saw before).
  • My advice: I don't waste much time configuring this here. I use Anti Gravity for specific developments where I give it all the necessary information in the prompt myself.

Agents: Planning vs. Fast (Phase)

As in OpenCode, here we have two working modes:

  1. Planning Mode: Ideal for complex tasks. The AI analyzes the whole project, proposes a plan and, if you give the OK, executes. It can even open a browser to verify if what it did works in real time.
  2. Fast Mode (Phase): It's the one I use the most. It is for quick and direct changes. You tell it: "Modify this file to include the CKEditor plugin" and it does it instantly.

My Workflow

When I work with AntiGravity, I follow a very marked cycle:

  1. Iteration: I request the change and wait for it to finish.
  2. Testing: If it works, I sync with Git (git commit).
  3. Correction: If it doesn't work, I ask it to correct.
  4. Reversion: If I see that the AI is getting confused and not getting it right, I use git checkout or git reset, clear the table and start again from a safe point.

Visual advantages

One thing I love about this tool is the visual help. It marks in yellow exactly which lines it is going to change before applying them. That gives you a security that you don't always have in the VS Code terminal.

Which one to choose? OpenCode vs. Antigravity

If you are on the free plan, this is the dilemma:

  • OpenCode: You have total customization, you can use all the skills from Laravel Boost and you have enormous flexibility, but it requires that you know the tool well.
  • Antigravity: You have the best models on the market (including Claude, GPT…) for free and a much friendlier interface for fast development, but with less capacity for deep auditing.

In summary: I use OpenCode for audits, database reviews or complex Laravel architectures, and Antigravity to create new features quickly and visually.

Redesign Practice with Google Antigravity

Let's put Google Anti Gravity to the test with a real exercise: a simple redesign of the Laravel application we are building. Although I trust the tool, my first recommendation is always the same: sync with Git before starting. That way, if you don't like the result, a git reset brings you back to life in seconds.

1. Setting the Stage (Context)

So that the AI doesn't hallucinate, it's best to give it context. In this case:

  • I copied the relative path of the css folder (where the blog and dashboard styles are located).
  • I looked for an inspiration image on the internet to give it as a visual reference.

2. The Prompt: Clear and Direct

My goal is not to change the HTML structure (so as not to complicate the course), but only the style. I used Fast Mode and the Gemini 3 Flash model. The prompt was something like this:

"Perform a simple redesign of the app, overwriting as much as possible only the CSS located at:

resources/css/blog.css

resources/css/dashboard.css

I want you to be inspired by the attached image."

Note: If you have doubts about how to approach a major change, use Planning Mode; but for something as specific as this, Fast Mode is perfect.

Execution and Results

Unlike OpenCode, here you see how the AI starts scanning the project visually. It marks in yellow exactly which CSS lines it is going to change or add. It is a brutal visual help.

This is a perfect example of how to interact with the AI in Anti Gravity:

  1. You ask: "Redesign this".
  2. You wait: You see how it applies the changes visually.
  3. You test: You register, authenticate and browse the app.
  4. You decide: Do you like the result? If the answer is "it's pretty good", you sync. If not, you revert with Git.

In my case, although there were small bugs that I will fix "behind the scenes", the result works for me.

Implement confirmation dialog when deleting

To close this practice with Google Antigravity, we are going to solve a pending detail: the confirmation dialog for deleting records. Currently, if you press "Delete", the post is deleted directly without asking, which is dangerous.

The Prompt and the Context

The good thing about Antigravity is that you don't need to be excessively specific if the code is well structured. I simply passed it the index.blade.php file of categories and asked:

Implement a confirmation dialog for the delete button:

resources/views/dashboard/post/index.blade.php

resources/views/dashboard/category/index.blade.php

I didn't have to tell it which button it was; the AI analyzed the context, found the tag that says "Delete" and understood perfectly where it should intervene.

The Solution: Native and Effective JavaScript

Look at what the AI did (you can see it in the yellow change bubbles): it implemented a solution with JavaScript directly in the onsubmit event of the form.

resources/views/dashboard/post/index.blade.php

resources/views/dashboard/category/index.blade.php

<form action="{{ route('category.destroy', $category) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this category?')">

How does this logic work?

  • It uses the native confirm() function, which displays a popup window in the browser.
  • This function returns true (if you click OK) or false (if you click Cancel).
  • By placing it inside the onsubmit, if the user cancels, the form receives a false and automatically stops the submission. It's a clean and direct way to protect data without needing to install heavy libraries.

Conclusion

This second practice demonstrates why I use Antigravity so much for what I call "spiritual development" (those quick adjustments that give you peace of mind).

If we had wanted something more complex, like a separate module in dashboard.js, the AI would have been in charge of creating the file, exporting the module and importing it correctly. What would take us several minutes of code "carpentry", the AI resolves in 3 or 4 minutes.

Laravel SDK AI

How to use a Local LLM with Laravel AI SDK and Jan.ai or LM Studio?

In this section, I will show you how to use a local large language model (LLM) using Jan.ai or LM Studio in conjunction with the Laravel AI SDK. This is an ideal solution if you are in regions where services like Gemini or ChatGPT are blocked, or if you are simply looking for privacy and cost savings.

What is a local LLM?

An LLM (Large Language Model) is an artificial intelligence designed to understand and generate human language. By running it locally, you take advantage of your own machine's power without depending on an internet connection or paid plans. The advantages are clear:

  • Privacy: Your data never leaves your computer.
  • No costs: There are no token limits or monthly subscriptions.
  • No blocks: You do not depend on geographical restrictions.

Tools and Requirements

To run a local AI, you need software that manages the model and a computer with a certain amount of power. The most popular options are Ollama, LM Studio, and Jan.ai.

Hardware Considerations (RAM vs. VRAM)

The power of the model you choose will depend on your memory:

  • Windows/Linux: They rely heavily on the video card's VRAM. If the model exceeds your capacity (e.g., you have 12GB of VRAM and try to run a heavy model), the system will use the RAM, and performance will drop drastically.
  • Mac (M Chips): They use unified memory. This means the CPU and GPU share the same RAM (e.g., 24GB), which allows for running larger models (like those with 31B parameters) more smoothly.

Local Server Configuration

Both Jan.ai and LM Studio follow the OpenAI API standard, which greatly facilitates integration with Laravel.

  • Installation: Download and install Jan.ai from its official site.
  • Activate the Server: Go to Settings > Local API Server and activate it.
  • Endpoint: By default, the server will run at http://localhost:1337/v1.
  • API Key: You can configure a simple key (e.g., 12345) in the local server settings.

LM Studio, like Jan.ai, is another excellent option available to us for installing local AIs, and it also features a local server just like Jan.ai; therefore, we can also use it for our project in Flask WITH VIRTUALLY NO CHANGES, but unlike Jan, LM Studio DOES NOT use a BEARER protection token and by default its port is 1234 (which you can change); in the following table, I summarize what was mentioned

FeatureJan.aiLM Studio
Default URLhttp://localhost:1337/v1http://localhost:1234/v1
API KeyAny text (e.g., "sk-local")Not required / Ignored
CompatibilityOpenAI Style (v1)100% OpenAI-compatible
HeadersContent-Type + AuthorizationContent-Type (Auth optional)
Main Enginenitro (specialized in GGUF)llama.cpp

You can download LM Studio from:

https://lmstudio.ai/

Its operation is very similar to Jan.ai, so you won't get lost; furthermore, just like Jan, you can connect to LM Studio exactly the same way because LM Studio exposes a 100% OpenAI-compatible API, in which we have some interesting endpoints we can use:

  • v1/chat/completions
  • /v1/models

LM Studio does not use an API KEY

This means you must remove the Authorization header because if you send it, LM Studio either ignores it or throws a 401 in some cases.

Integration with Laravel AI SDK

To connect Laravel with your local AI, we must perform a small "trick" in the .env file. Since the Laravel SDK does not have a native driver for Jan.ai, we will use the Deepseek or OpenAI driver to "bridge" (OpenAI Driver Emulation) the connection.

Configuration in the .env

# We use the Deepseek driver to take advantage of its endpoint compatibility
AI_DRIVER=deepseek
DEEPSEEK_API_KEY=your_local_key_12345
DEEPSEEK_BASE_URL=http://localhost:1337/v1

Publish the configuration file:

$ php artisan vendor:publish --provider="Laravel\Ai\AiServiceProvider"

Connect a Local LLM with Jan.ai to a Python app with Flask

In this section, we are going to connect to the Local LLM.

Activate the local server

Before connecting, we must activate the Jan.ai server:

Settings → Local App Server → Set your key → Start

In LM Studio: Go to the Local Server tab (the double arrow or developer icon).

Click the Start Server button. (Unlike Jan.ai, you don't need to configure an API key here; access is direct via the base URL provided by the program.)

Base controller to connect to the LLM

Regarding the main part, the controller that will connect to the LLM, we have:

  • URL and headers

We define the endpoint URL to which we will make the request:

http://localhost:port/v1/chat/completions

And the headers that Jan needs:

headers = {
    "Content-Type": "application/json",
    "Authorization": "Bearer 12345"
}
  • Model → the one you have selected in Jan (you must write it identically, for example: gemma-3-12b).
  • Message → JSON array with the user role and the content.

In this example: “tell me a joke”.

data = {
    "model": "gemma-3-12b-it-IQ4_XS",
    "messages": [
        # {"role": "user", "content": request.form.get("prompt", "Tell me a joke.")}
        {"role": "user", "content": prompt}
    ]
}

Making the request

Since this can fail, we wrap it in a try block.

We perform a requests.post(...) sending:

  • URL.
  • headers.
  • json=data (no body, because it is explicitly JSON).

Then we validate the response: if response.status_code == 200, everything is perfect.

  • 404 would be route not found.
  • 500 = something exploded on the server or you sent something invalid.

Execution in code

To make a request, you can use an agent or a direct call. It is vital that the model name matches exactly with the ID provided by Jan.ai (you can check it in the list of downloaded models or via Postman).

use Illuminate\Support\Facades\AI;
$response = AI::chat('Generate a list of 3 Laravel 13 topics in JSON format')
   ->withDriver('deepseek')
   ->withModel('gemma-2-9b-it') // Replace with your local model ID
   ->send();
return $response;

Which local model to select

If you don't know which model to download, I recommend starting with Gemma (from Google) or Llama. If you have doubts about which one your computer supports, you can take a screenshot of your technical specifications and ask the AI itself; it will tell you if a light 7B parameter model suits you or a more robust 31B one.

Prompt: I have a Mac mini M4 Pro, which one should I use? Give me a description for what to use each one for and which one you recommend.

  1. The "Heavyweights" (31B) - For Complex Reasoning
    Models with 31 billion parameters. They are the smartest on the list.
    1. Gemma-4-31B-It-GGUF:
    2. What for: Ideal for when you need the AI to understand complex software architecture, solve difficult bugs in Laravel 13, or draft deep educational content for your academy.
    3. Performance on M4 Pro: It will run well, but will consume a lot of RAM (about 16-18 GB for the model alone). If you have 24GB or more of RAM, this is your primary model.
  2. The "Balance" (26B) - The All-rounder
    Models with 26 billion parameters.
    1. Gemma-4-26B-A4B-It-GGUF:
    2. What for: It is a "Mixture of Experts" (MoE) architecture. It is faster than the 31B but maintains almost the same intelligence. Perfect for daily code generation and unit testing.
    3. Performance on M4 Pro: It will fly. It is the sweet spot between speed and "brain".
  3. The "Lights" (E4B / E2B) - For Automation and Speed
    Models with 4 and 2 billion parameters.
    1. Gemma-4-E4B / E2B:
    2. What for: These are "Edge" models, designed to be ultra-fast. Use them for simple tasks like code autocompletion, JSON formatting, or summarizing short texts.
    3. Performance on M4 Pro: Instantaneous. You will hardly notice it processing.

Analysis for the RTX 5070

Unlike the Apple processor, the 5070 has Tensor cores specifically designed for AI. This means the models you choose will respond almost in real-time.

  • Gemma-4-26B-A4B-It-GGUF (Recommended):
    • Why: Being a 26B MoE (Mixture of Experts) architecture, it only activates a fraction of its parameters for each word (in this case, 4 billion active).
    • On the 5070: It will fit very well in VRAM if you use Q4_K_M or Q5 type quantization. You will have a text generation speed superior to the Mac.
    • Gemma-4-31B-It-GGUF (The Limit):
  • Why: This model is dense (always uses all 31B).
    • On the 5070: It is likely to "overflow" the video card's VRAM. When a model doesn't fit in the video card, it moves to normal RAM (the i7 14700K), and the speed drops drastically (from 50 tokens per second to 2 or 3).
    • Usage: Only use it if you need the highest possible precision for a complex tutorial and don't mind waiting a few extra seconds.
    • Gemma-4-E4B-It-GGUF (For development):
  • Why: It is small (4B).
    • On the 5070: It is instantaneous. You can use it as a co-pilot that suggests code in real-time while you write your books or Laravel courses without the GPU fan even flinching.

With this, you now have your own fully private and functional artificial intelligence environment in Laravel!

Practical Anonymous Agents Examples: Chat, Code, and Sentiment Analysis

To give you more variety, I have prepared three additional examples. Although the base is similar to what we have seen, these cases will help you visualize how to structure a real and functional project in the future, beyond a simple static request.

I have created a new controller with three specific methods:

  • General Chat: An open conversation.
  • Code Generation: Creating structures such as Laravel migrations.
  • Sentiment Analysis: Determining if a message is positive, negative, or neutral.

routes\web.php

// Basic Chat
Route::get('/agents/chat', [AgentsTestController::class, 'chat']);
// Code generation
Route::get('/agents/generate-code', [AgentsTestController::class, 'generateCode']);
// Sentiment analysis
Route::get('/agents/analyze', [AgentsTestController::class, 'analyze']);

app\Http\Controllers\AgentsTestController.php

<?php
namespace App\Http\Controllers;
use App\Ai\Agents\PokemonAgent;
use App\Ai\Agents\QuizAgent;
use App\Ai\Agents\QuizGenerator;
use App\Ai\Agents\SalesCoach;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Laravel\Ai\Enums\Lab;
use function Laravel\Ai\agent;
/**
 * Controller for examples using Gemma 3 with local AI (Ollama).
 */
class AgentsTestController extends Controller
{
    /**
     * Basic Chat
     */
    public function chat(Request $request): JsonResponse
    {
        $message = $request->input('message', 'Hello, tell me what you know about Laravel');
        $response = agent(
            instructions: 'You are a helpful and concise assistant.',
        )->prompt(
                $message,
                model: <YOURIDLOCALMODAL>
            );
        return response()->json([
            'answer' => $response->text,
            'model' => 'gemma3:12b-it-IQ4_XS (Ollama)',
        ]);
    }
    /**
     * Code generation
     */
    public function generateCode(Request $request): JsonResponse
    {
        $type = $request->input('type', 'users migration');
        $response = agent(
            instructions: 'You are a Laravel expert. You generate clean code and follow best practices.',
        )->prompt(
                "Generate {$type} in Laravel 13. Only give me the code, without explanations.",
                model: <YOURIDLOCALMODAL>
            );
        return response()->json([
            'code' => $response->text,
            'request' => $type,
        ]);
    }
    /**
     * Text analysis
     */
    public function analyze(Request $request): JsonResponse
    {
        $text = $request->input('text', 'This product is incredible, I totally recommend it');
        $response = agent(
            instructions: 'You are a sentiment analyzer. You respond only with positive, negative, or neutral.',
        )->prompt(
                "Classify the following text: {$text}",
                model: <YOURIDLOCALMODAL>
            );
        return response()->json([
            'sentiment' => trim($response->text),
            'original_text' => $text,
        ]);
    }
}

Analysis of the new examples

1. Dynamism with request()->input()

For this to be a real web application, we need the user to interact. I have configured the methods to look for a field called "text" in the request. If it doesn't exist, we use a default value:

$input = $request->input('text', 'What do you know about Laravel?');

This will allow us to easily connect our logic to a user form in the future.

2. Anonymous Agents and Sentiment Analysis

In these examples, we are using Anonymous Agents. These are agents that we define "on the fly" with specific instructions. For example, for the sentiment analyzer:

Instruction: "You are an expert at analyzing sentiments. Respond only: Positive, Negative, or Neutral".

Input: "This product is incredible, I recommend it!"

Expected result: Positive.

3. Custom Code Generation

Here we combine a fixed prompt with the user's input. We tell the AI that it is an expert in Laravel 13 and that it must generate code without additional explanations.

$type = $request->input('type', 'users migration');
->prompt("Generate {$type} in Laravel 13. Only give me the code, without explanations.",***)

If the user asks for "users migration", the system generates the exact database schema.

Response examples

http://larapackage.test/agents/chat

{"answer":"Laravel is an open-source PHP framework designed to facilitate web application development following the MVC (Model-View-Controller) pattern. Its main features are:\n\n- **Eloquent ORM**: active and simple model for interacting with databases.\n- **Declarative routes**: clear definition of URLs and associated controllers.\n- **Middleware**: filtering HTTP requests before reaching business logic.\n- **Blade**: lightweight template engine that allows writing views with clean PHP syntax.\n- **Migrations & Seeders**: structured management of database schema and initial data.\n- **Queues and events**: asynchronous processing and event-based architecture.\n- **Security**: CSRF protection, password hashing, out-of-the-box authentication.\n\nLaravel follows the \u201cconvention over configuration\u201d principle, which speeds up productivity without sacrificing flexibility. It is ideal for both small projects and complex enterprise applications.","model":"gemma3:12b-it-IQ4_XS (Ollama)"}

http://larapackage.test/agents/generate-code

{"code":"```php\n<?php\n\nuse Illuminate\\Database\\Migrations\\Migration;\nuse Illuminate\\Database\\Schema\\Blueprint;\nuse Illuminate\\Support\\Facades\\Schema;\n\nreturn new class extends Migration\n{\n    public function up(): void\n    {\n        Schema::create('users', function (Blueprint $table) {\n            $table->id();\n            $table->string('name');\n            $table->string('email')->unique();\n            $table->timestamp('email_verified_at')->nullable();\n            $table->string('password');\n            $table->rememberToken();\n            $table->timestamps();\n        });\n    }\n\n    public function down(): void\n    {\n        Schema::dropIfExists('users');\n    }\n};\n```","request":"users migration"}

http://larapackage.test/agents/analyze

{"sentiment":"positive","original_text":"This product is incredible, I totally recommend it"}

Final observations about the responses

Remember that, even if the prompt is the same, AIs are stochastic; that is, they will not always give the exact same answer, which is part of the magic of natural language processing.

If you notice that the connection takes time or fails occasionally, it is usually due to the load on your hardware (CPU/GPU) when processing the model locally and not a programming error. We already have the foundations! In the next class, we will stop using anonymous agents to create structured Agents, with their own classes and a much more robust logic.

There are many errors that can occur such as:

Illuminate\Http\Client\ConnectionException
vendor\laravel\framework\src\Illuminate\Http\Client\PendingRequest.php:1805
cURL error 28: Operation timed out after 60013 milliseconds with 0 bytes received (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for http://127.0.0.1:1337/v1/chat/completions
--
Error [400]: Unknown error

Structured Agents: Beyond Anonymous Agents

So far we have worked with anonymous agents, which are the fastest and simplest way to start. However, in the Laravel AI SDK documentation, we find what we can call "named agents" or agent classes. These allow us to organize AI logic in a much more professional and reusable way.

1. Structure of an Agent

When creating an agent (like the SalesCoach example), we find a very interesting structure:

  • Constructor: Allows passing external data to the agent.
  • Instructions (instructions): Defines the behavior or role of the AI.
  • Messages (messages): An internal method to load previous conversations or historical context. This is vital so the AI doesn't "start from scratch" in each interaction.
  • Tools (tools): Allow the AI to execute programmatic functions (for example, generating a random number or querying an external API) to solve specific tasks.

Creating Agents via CLI

To generate these agents, the SDK offers two main commands that define how the AI's response will be:

Standard Agent:

$ php artisan make:agent SalesCoach

Creates a basic structure with instructions, messages, and tools, but without a mandatory output format.

Agent with Structure (Schema):

$ php artisan make:agent SalesCoach --structured
<?php
namespace App\Ai\Agents;
use App\Ai\Tools\RetrievePreviousTranscripts;
use App\Models\User;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use Stringable;
class SalesCoach implements Agent, HasTools //Conversational
{
    use Promptable;
    public function __construct(public ?User $user = null) {}
    /**
     * Get the instructions that the agent should follow.
     */
    public function instructions(): Stringable|string
    {
        return 'You are a sales coach, analyzing transcripts and providing feedback and an overall sales strength score.';
    }
    /**
     * Get the tools available to the agent.
     *
     * @return Tool[]
     */
    public function tools(): iterable
    {
        ***
    }
    /**
     * Get the agent's structured output schema definition.
     */
     public function schema(JsonSchema $schema): array
     {
         return [
             'feedback' => $schema->string()->required(),
             'score' => $schema->integer()->min(1)->max(10)->required(),
        ];
     }
}

This is the most interesting one for us, as it implements a Schema.

In Laravel we are used to everything being heavily structured. If you query a Post model, you know exactly what fields to expect. If you use an all() method, you expect a collection. With AI, if we don't define a schema, the response format can vary and break our application.

Why use structured responses?

By defining a schema, we force the AI to make its response fit exactly into the format we need (usually JSON). This is excellent because:

  • Integration: You can map the AI response directly to a Livewire component or a controller.
  • Consistency: If you have a planets application, you can demand that the AI always returns: name, description, diameter, and density.
  • Security: You ensure that your code can process the data without unexpected formatting errors.

The Pokémon example

For this example, I have configured an expert Pokémon agent. The instruction is clear: "You are a Pokémon expert, generate realistic data in exact JSON format." Although the SDK allows defining the structure through code, it is always good practice to reinforce the desired format in the prompt.

This part is fundamental because it works very similarly to form validations in Laravel, ensuring data integrity.

routes\web.php

// Structured Output: Pokemon list with PokemonAgent
Route::get('/agents/pokemon-list', [AgentsTestController::class, 'listPokemon']);

app\Http\Controllers\AgentsTestController.php

public function listPokemon(Request $request): JsonResponse
{
    $result = (new PokemonAgent)->prompt(
        'Generate a list of 3 different Pokemon',
        model: <YOURIDLOCALMODAL>
        timeout: 120
    );
    // dd($result);
    return response()->json([
        'result' => $result->toArray(),
        'schema' => [
            'pokemon' => [
                '*' => [
                    'name' => 'string',
                    'type' => 'enum',
                    'size' => 'integer',
                ],
            ],
        ],
    ]);
}

The agent looks like:

app\Ai\Agents\PokemonAgent.php

<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
 * Agent to generate Pokemon list with structured schema.
 *
 * Schema returned:
 * - pokemon: array of objects
 * - name: string
 * - type: enum (fire, water, electric, grass, normal, flying, psychic)
 * - size: integer (10-500 cm)
 */
// php artisan make:agent SimpleAgent --structured
class PokemonAgent implements Agent, HasStructuredOutput
{
    use Promptable;
    public function instructions(): Stringable|string
    {
        return 'You are a Pokemon expert. You generate realistic Pokemon lists in exact JSON format.';
    }
    public function schema(JsonSchema $schema): array
    {
        return [
            'pokemon' => $schema->array()->items(
                $schema->object([
                    'name' => $schema->string()->required(),
                    'type' => $schema->string()->enum([
                        'fire',
                        'water',
                        'electric',
                        'grass',
                        'normal',
                        'flying',
                        'psychic',
                        'rock',
                        'ground',
                    ])->required(),
                    'size' => $schema->integer()->min(10)->max(500)->required(),
                ])
            )->required(),
        ];
    }
}

To ensure that the AI returns exactly what our application needs, we use the internal validation methods of the SDK. The structure is defined as follows:

  • Name: String type and required.
  • Types: A list of allowed values (fire, water, electric, etc.).
  • Size: An integer value with a defined minimum and maximum range.

Quiz Generator Example (Q&A)

The main instruction for this agent is: "Generate questions and answers based on the content of the posts; generate True or False type questions." To ensure the output is processable, we define a Schema with three mandatory fields:

  • Question: The statement of the inquiry.
  • Answer: The boolean value (True/False).
  • Explanation: The reasoning behind the answer to help the student.

routes\web.php

// True/False Quiz based on Posts (simple version with prompt)
Route::get('/agents/quiz-simple', [AgentsTestController::class, 'quizPostsSimple']);

We can pass the number of questions we want (defaulting to 5) and a list of identifiers (IDs). To process the IDs, we use the PHP explode function, which converts a comma-separated string (e.g., 1,2,3) into an array, allowing us to use Eloquent's whereIn method.

app\Http\Controllers\AgentsTestController.php

public function quizPostsSimple(Request $request): JsonResponse
{
    $ids = $request->input('ids'); // 1,2,3
    $cantidad = $request->input('cantidad', 5);
    $posts = Post::when($ids, fn($query) => $query->whereIn('id', array_filter(explode(',', $ids))), fn($query) => $query->where('posted', 'yes')->limit(1))
        ->get(['title', 'content']);
    if ($posts->isEmpty()) {
        return response()->json(['error' => 'No hay contenido'], 404);
    }
    
    $contenido = $posts->map(fn($post) => "Título: {$post->title}\nContenido: {$post->content}")->join("\n\n---\n\n");
    $prompt = "Basándote en los siguientes posts, genera {$cantidad} preguntas de verdadero o falso:\n\n{$contenido}";
    $resultado = (new QuizAgent)->prompt(
        $prompt,
        model: <YOURIDLOCALMODAL>
    );
    return response()->json([
        'quiz' => $resultado->toArray(),
        'posts_utilizados' => $posts->count(),
    ]);
}

Once the post is obtained, we extract the title and the body of the article to feed the agent. It is important to distinguish between the agent's instruction (its general role) and the specific task (the current prompt), which in this case tells it exactly how many questions to generate based on the provided text.

The agent remains as follows:

app\Ai\Agents\QuizAgent.php

<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
 * Agent to generate True/False quizzes based on posts.
 *
 * Returned Schema:
 * - questions: array of objects
 * - question: string
 * - answer: boolean (true = true, false = false)
 * - explanation: string
 */
class QuizAgent implements Agent, HasStructuredOutput
{
    use Promptable;
    public function instructions(): Stringable|string
    {
        return 'You are a quiz question generator. Based on the content of the posts, you generate true or false questions. The questions must be clear and the correct answer must be based on the content.';
    }
    public function schema(JsonSchema $schema): array
    {
        return [
            'preguntas' => $schema->array()->items(
                $schema->object([
                    'pregunta' => $schema->string()->required(),
                    'respuesta' => $schema->boolean()->required(),
                    'explicacion' => $schema->string()->required(),
                ])
            )->required(),
        ];
    }
}

Example output:

  • Question: "Is Laravel Volt a runtime environment for PHP?"
  • Answer: "False".
  • Explanation: "Laravel Volt is a functional API for Livewire that allows writing logic and templates in the same file."

The importance of the Schema

Here we reach what you mention as the "Achilles heel" of AI in software development: the lack of structure.

In Laravel we are used to everything being heavily structured. If you query a Post model, you know exactly what fields to expect. If you use an all() method, you expect a collection. With AI, if we don't define a schema, the response format can vary and break our application.

Why use structured responses?

By defining a schema, we force the AI to make its response fit exactly into the format we need (usually JSON). This is excellent because:

  • Integration: You can map the AI response directly to a Livewire component or a controller.
  • Consistency: If you have a planets application, you can demand that the AI always returns: name, description, diameter, and density.
  • Security: You ensure that your code can process the data without unexpected formatting errors.

You already know how to generate these agents and why structure is the key to integrating artificial intelligence into a developer's workflow. We won't always want simple text on the screen; most of the time we will need data that our application can understand and process.

The Quiz Agent: From Example to Real-World Utility

I want to delve deeper into the "Quiz" agent that we implemented earlier. Beyond the theoretical examples we've already seen, I want us to analyze it from a functional and realistic point of view. My purpose is for you to understand how to adapt a similar scheme for any other task you need in your own projects.

To recap: this agent has a fixed task, which is to generate questions and answers in a specific format. On this occasion, I am not using the course project, but one of my own where I am migrating my current application to a clean environment. I have decided to integrate this quiz generator because, as you know, my books and courses are closely related (one-to-one relationship); therefore, all the written content is perfect material for evaluating the student.

The Problem with Manual Creation

Creating labs or quizzes manually (for example, for Udemy) is a real pain. It requires a lot of time to think about which questions fit best in each section, write the correct and incorrect options, and give them variety. Due to lack of time, I often don't include them, and this is precisely where AI becomes an invaluable tool to solve a real problem in my workflow.

Technical Implementation with Livewire and PHP

In this implementation, I use Livewire. To call the method that generates the quiz, I use a wire:click from the view. If you haven't mastered Livewire yet, don't worry (we'll see it later); for now, think of this simply as a form that, upon submission, executes a function on the server written in PHP:

public function generarQuiz()
    {
        // $sections = BookSection::where('book_id', $this->model->id)->where('posted', 'yes')->where('orden', '>', 0)->get();
        $sections = BookSection::where('book_id', $this->model->id)->where('posted', 'yes')->where('orden', 4)->get();
        foreach ($sections as $s) {
            $resultado = (new QuizAgent)
***
<flux:button size="xs" variant="danger" wire:click='generarQuiz'>
     <flux:icon name="trash" />
</flux:button>            

The main advantage here is total integration. Unlike other approaches where you might use an external tool or an isolated assistant (such as custom GPTs or CLI tools), here everything happens within the same application. We don't need to connect external databases or export files; the PHP code reads the book chapter directly from the database and passes it to the agent.

Content and Context Analysis

The content is extensive since they are ENTIRE chapters of my books. Making 30 or 40 questions from this content manually could take an hour per chapter. With the agent, I simply pass the full text and it handles the heavy lifting.

Advantages Over the External Assistants Model

  • Before reaching this solution, I used a much more rudimentary scheme:
  • Extracted the information from the database.
  • Pasted it into a local assistant (using Jan.ai or LM Studio).
  • Requested the output in JSON.
  • Converted that JSON to SQL to insert it into my database.

This flow was horrible and constantly failed due to context limits or incomplete responses. Direct integration in Laravel eliminates these ambiguities. By having the agent encapsulated, I can pass "super-prompts" that include the full content of the chapter without leaving my development environment.

Real-World Testing and Local Performance

I am currently testing this system with a local LLM. It is important to mention that, although I have a powerful Mac, trying to run very heavy models (like one with 31B parameters) can cause 500 errors without a clear description, simply because the system runs out of memory or exceeds timeout limits:

DeepSeek Error [500]: server_error - Compute error.
LARAVEL
13.4.0
PHP
8.4.19

For these practices, I have configured Gemma 3 12B and I am evaluating the results. Note that the process can take time (which is why using Queues and Jobs in Laravel is ideal), but the result is coherent:

Conclusion and Next Steps

This "Pro" approach allows the AI to read your documents, Excels, or databases like crazy and generate immediate value. In the following classes, we will keep tweaking this assistant to improve the output format and optimize the processing of longer chapters, since the larger the content, the more processing power we require.

AI Agent + Work Queues

We are going to take our agent to the next level by using work queues. The reason is simple: AIs are often slow to respond, especially when we depend on third-party services or heavy local processes.

Laravel + AI

The beauty of having Laravel as an extremely robust framework, which allows us to do everything, together with AI, is that in this case we are not going to use Laravel as the bus for development, but as a tool to call and perform an action. In this case, just as we have been doing since the previous class, remember that I am linking it; it is the same agent, only here I called it differently, which is "chunks." I will explain why in a moment.

Laravel offers us a robust framework to handle these background tasks, allowing the AI to work as an external tool without blocking the main thread of our application.

The Technical Challenge: Massive Data Processing with AI

In a real development environment, the main problem is not just connecting to the AI, but how to manage the flow of information effectively. My current use case is a clear example: I need to generate a large number of questions and answers based on the theoretical content of my books.

Although this flow can be adapted to a thousand different scenarios, the constant remains the same: you have a large amount of data that must be processed without compromising the stability of the application.

1. The Risk of Interface Blocking

It is not viable to process these requests directly on the main thread. If we try to call the AI synchronously, the application will inevitably "hang" for seconds or even minutes. This creates a poor user experience and can cause timeout errors in the browser or on the server.

2. Efficiency and Scalability

For the processing to be truly effective, we must implement an architecture that allows:

  • Decoupling the request from the processing: The user requests the generation and the application responds immediately, while the heavy lifting happens in the background.
  • Work Queue Management: Utilize tools like Laravel's queues to manage tasks, allowing them to run one after another in an organized manner.
  • Chunking: Divide the information into manageable pieces to avoid saturating server memory or AI context limits.

The Problem: The Blocked Main Thread

Previously, we called the agent directly from a Livewire class. By doing so via wire:click, the browser and the application would "hang" while waiting for the AI response.

In a real environment, if you need to process large volumes of data (such as generating hundreds of questions based on books), you cannot allow the interface to freeze. To solve this, we migrated the logic to a Laravel Job.

Load Management and Use of Local AIs

For our system to be stable, it is not enough to send tasks to the queue; we must be strategic with resource consumption. If you saturate your machine, the operating system (whether Windows or macOS) could terminate processes due to lack of memory or excessive load, which would interrupt the generation of your content.

1. The "Drip" Task Strategy

The main recommendation is to avoid sending a massive amount of jobs all at once. It is much more effective to pass a small group of tasks and, as the Laravel Workers become free, send the next batch.

In my case, I am processing fragments of my books. Instead of trying to have the AI "read" the entire book at once, I pass specific fragments directly in the message.

2. Input Format: Context in the Prompt

We are currently using the most traditional method: including all the theoretical content directly in the prompt's introduction.

  • Advantage: It is direct and easy to implement at the start.
  • Limitation: The prompt size has a limit (context window). If the fragment is too large, the AI may lose precision or simply fail.

3. Implementation with Local AIs

One of the great advantages of this flow is the possibility of using local models. This is ideal for working with sensitive data or simply to save on API costs.

  • Ollama: It is currently the most robust and "official" option for integrating local models into applications.
  • Alternatives: Although excellent tools like Jan.ai or LM Studio exist, direct integration via libraries is usually more optimized for Ollama.

4. Why use Local AIs?

The use of local models (like Llama 3 or Mistral) allows us to perform unlimited tests at no additional cost. We only need to ensure that our PC has the necessary power (especially in the GPU) to respond in acceptable times.

In addition to handling ALL data privately

Structure of the Work Queue (Job)

The Job is responsible for receiving content fragments (segmented into chunks) so that the AI does not collapse. These are the key configuration points:

  • Execution Time: I have configured a high timeout (for example, 900 seconds) and the set_time_limit(0) method to prevent PHP or the queue system from killing the process prematurely.
  • Tries: It is vital to handle exceptions. If the AI fails due to workload, the system will automatically retry the task.
    • public $timeout = 900;
      public $maxExceptions = 3;
      public $memory = 512;
      public $tries = 3;
    • In addition to other parameters such as the amount of allocated memory.
  • Content Segmentation: We do not pass the entire book. We divide the text into fragments of about 1,500 characters. This ensures that local models (like Gemma 3 12B via Jan.ai or Ollama) can process the information effectively.
    • foreach ($sections as $s) {
         $chunks = mb_str_split($s->content, 15000, 'UTF-8');
         foreach ($chunks as $content) {
            GenerateChunkQuizJob::dispatch(

JSON Schema Definition and Data Model

For the AI response to be useful, it must be structured. I have updated the schema to return a "Trivia" type format (Duolingo style):

  • Question: The challenge text.
  • Options: An array of strictly 4 options.
  • Correct Answer: The index of the valid option and an explanation of why it is correct or incorrect.
$resultado = (new QuizAgent)
    ->prompt(
        "
        Act as a programming teacher. 
        Read this fragment of my book: '{$content}'
        Generate a quiz of {$this->numQuestions} questions based solely on that text.
        Respond ONLY with valid JSON, without additional text or markdown.
        Required structure:
        {
            \"questions\": [
            {
                \"text\": \"question\",
                \"options\": [\"option1\", \"option2\", \"option3\", \"option4\"],
                \"options_message\": [\"message1\", \"message2\", \"message3\", \"message4\"],
                \"correct_answer_index\": 0,
                \"language\": \"spanish\"
            }
            ]
        }
        Give it to me in {$this->language}
    ",
        model: $this->modelAi
    );
public function schema(JsonSchema $schema): array
{
    return [
        'questions' => $schema->array()->items(
            $schema->object([
                'text' => $schema->string()->required(),
                'options' => $schema->array()->items(
                    $schema->string()
                )->min(4)->max(4)->required(),
                'options_message' => $schema->array()->items(
                    $schema->string()
                )->min(4)->max(4)->required(),
                'correct_answer_index' => $schema->integer()->required(),
                'language' => $schema->string()->enum(['english', 'spanish'])->required(),
            ])
        )->required(),
    ];
}

The migration looks like this:

Schema::create('learning_questions', function (Blueprint $table) {
    $table->id();
    $table->text('text');
    // $table->enum('question_type', ['multiple_choice', 'code'])->default('multiple_choice');
    $table->json('options'); // Stores options as a JSON array
    $table->json('options_message'); // Stores messages as a JSON array
    $table->unsignedInteger('correct_answer_index');
    $table->enum('language', ['english', 'spanish'])->default('spanish');
    // Assuming book_sections and tutorial_sections are other tables
    $table->foreignId('book_section_id')->nullable()->constrained('book_sections')->onDelete('cascade');
    $table->foreignId('tutorial_section_id')->nullable()->constrained('tutorial_sections')->onDelete('cascade');
    $table->timestamps();
});

The interesting thing about this system is the synergy we achieve between an Eloquent model and the responses generated by the AI. By using JSON-type fields in our database migrations, we eliminate the friction of having to map each field individually, allowing the data to flow directly from the language model's response into our application.

1. The Use of JSON Fields in Migrations

  • In the Learning model, we have defined the table structure with JSON columns. This offers us enormous flexibility:
  • Direct Persistence: We save the object exactly as returned by the AI (after prior validation).
  • Ease of Processing: Laravel automatically converts these fields into PHP arrays or collections, facilitating their use in the frontend or business logic.

2. Linking Between Laravel and AI

The key here is the direct relationship. We are not simply receiving plain text; we are integrating the AI's reasoning capability with Laravel's robustness.

  • The flow is clean: we send a book chapter as context.
  • The AI generates the questions and options under a predefined schema.
  • Laravel receives, validates, and inserts the result into the database.

3. Automation of Data Flow

The ultimate goal is total automation. Unlike a manual process where you would have to copy and paste each question, here the AI acts as an intelligent data processor.

  • Quality Verification: Although the AI does the heavy lifting, the system includes a validation layer to ensure the response is valid JSON and meets requirements before insertion.
  • Consistency: By tying the prompt to a specific book chapter, we guarantee that the answers are not "made up" at random, but are strictly based on the theoretical content provided.

This approach transforms Laravel into more than just a web development framework; it turns it into a knowledge management engine. By delegating structured content creation to the AI and data management to Eloquent, we can generate massive educational platforms with minimal manual effort.

Response Validation and "Cleaning"

We cannot blindly trust that the AI will return a perfect JSON. Therefore, we implement validation logic:

  • Extract JSON: A method to clean the AI's response and extract only the JSON code block, removing additional text.
  • Integrity Check: We verify that the JSON is decodable and contains all required fields before inserting it into the database.
  • Error Logs: If decoding fails, the error is recorded in storage/logs/laravel.log for debugging.
if (! $jsonText) {
    \Log::error('GenerateQuizJob: No JSON found in response', [
        'book_section_id' => $this->bookSection->id,
        'response' => mb_substr($responseText, 0, 1000),
    ]);
    return;
}
$data = json_decode($jsonText, true);
if (json_last_error() !== JSON_ERROR_NONE) {
    \Log::error('GenerateQuizJob: Invalid JSON after extract', [
        'book_section_id' => $this->bookSection->id,
        'json_error' => json_last_error_msg(),
    ]);
    return;
}
if (! isset($data['preguntas'])) {
    \Log::warning('GenerateQuizJob: No preguntas key in response', [
        'book_section_id' => $this->bookSection->id,
        'keys' => array_keys($data ?? []),
    ]);
    return;
}
foreach ($data['preguntas'] as $pregunta) {
    LearningQuestion::create([
        'text' => $pregunta['text'],
        'options' => $pregunta['options'],
        'options_message' => $pregunta['options_message'] ?? [],
        'correct_answer_index' => $pregunta['correct_answer_index'],
        'language' => $pregunta['language'],
        'book_section_id' => $this->bookSection->id,
    ]);
}

Real-Time Execution

To get everything running, we execute the command:

$ php artisan queue:work

The complete codes are as follows:

app\Jobs\GenerateChunkQuizJob.php

<?php
namespace App\Jobs;
use App\Ai\Agents\QuizAgent;
use App\Models\BookSection;
use App\Models\LearningQuestion;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\SerializesModels;
class GenerateChunkQuizJob implements ShouldQueue
{
    use Queueable, SerializesModels;
    public $timeout = 900;
    public $maxExceptions = 3;
    public $memory = 512;
    public function __construct(
        public BookSection $bookSection,
        public string $contentChunk,
        public int $numQuestions = 3,
        public string $modelAi = 'gemma-3-12b-it-IQ4_XS',
        public string $language = 'español',
    ) {}
    public function handle(): void
    {
        $resultado = (new QuizAgent)
            ->prompt(
                "
                Act as a programming teacher. 
                Read this fragment of my book: '{$this->contentChunk}'
                Generate a quiz of {$this->numQuestions} questions based solely on that text.
                Respond ONLY with valid JSON, without additional text or markdown.
                Required structure:
                {
                  \"preguntas\": [
                    {
                      \"text\": \"pregunta\",
                      \"options\": [\"opción1\", \"opción2\", \"opción3\", \"opción4\"],
                      \"options_message\": [\"mensaje1\", \"mensaje2\", \"mensaje3\", \"mensaje4\"],
                      \"correct_answer_index\": 0,
                      \"language\": \"spanish\"
                    }
                  ]
                }
                Give it to me in {$this->language}
            ",
                model: $this->modelAi
            );
        $responseText = trim((string) $resultado);
        $jsonText = $this->extractJson($responseText);
        if (!$jsonText) {
            \Log::error('GenerateChunkQuizJob: No JSON found in response', [
                'book_section_id' => $this->bookSection->id,
                'response' => mb_substr($responseText, 0, 1000),
            ]);
            return;
        }
        $data = json_decode($jsonText, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            \Log::error('GenerateChunkQuizJob: Invalid JSON after extract', [
                'book_section_id' => $this->bookSection->id,
                'json_error' => json_last_error_msg(),
            ]);
            return;
        }
        if (!isset($data['preguntas'])) {
            \Log::warning('GenerateChunkQuizJob: No preguntas key in response', [
                'book_section_id' => $this->bookSection->id,
                'keys' => array_keys($data ?? []),
            ]);
            return;
        }
        foreach ($data['preguntas'] as $pregunta) {
            LearningQuestion::create([
                'text' => $pregunta['text'],
                'options' => $pregunta['options'],
                'options_message' => $pregunta['options_message'] ?? [],
                'correct_answer_index' => $pregunta['correct_answer_index'],
                'language' => $pregunta['language'],
                'book_section_id' => $this->bookSection->id,
            ]);
        }
    }
    private function extractJson(string $text): ?string
    {
        $text = trim($text);
        if (preg_match('/```json\s*([\s\S]*?)```/', $text, $matches)) {
            return trim($matches[1]);
        }
        if (preg_match('/\{[\s\S]*\}/', $text, $matches)) {
            return trim($matches[0]);
        }
        return null;
    }
}

app\Ai\Agents\QuizAgent.php

<?php
namespace App\Ai\Agents;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
use Stringable;
/**
 * Agent to generate quiz questions with multiple options.
 *
 * Returned schema:
 * - preguntas: array of objects
 * - text: string
 * - options: array of 4 strings
 * - options_message: array of 4 strings
 * - correct_answer_index: integer (0-3)
 * - language: enum ('english', 'spanish')
 */
class QuizAgent implements Agent, HasStructuredOutput
{
    use Promptable;
    public function instructions(): Stringable|string
    {
        return 'You are a generator of quiz questions with multiple options. Generate questions with 4 theoretical options. Valid options are saved in "options" as a JSON array. True/false or success/error messages are saved in "options_message" as a JSON array. The correct option is indicated in "correct_answer_index" (0-3) and the language in "language".';
    }
    public function schema(JsonSchema $schema): array
    {
        return [
            'preguntas' => $schema->array()->items(
                $schema->object([
                    'text' => $schema->string()->required(),
                    'options' => $schema->array()->items(
                        $schema->string()
                    )->min(4)->max(4)->required(),
                    'options_message' => $schema->array()->items(
                        $schema->string()
                    )->min(4)->max(4)->required(),
                    'correct_answer_index' => $schema->integer()->required(),
                    'language' => $schema->string()->enum(['english', 'spanish'])->required(),
                ])
            )->required(),
        ];
    }
}
public function generateQuiz()
{
    $sections = BookSection::where('book_id', $this->model->id)
        ->where('posted', 'yes')
        ->where('orden', '>', 0)
        ->get();
    foreach ($sections as $s) {
        $chunks = mb_str_split($s->content, 15000, 'UTF-8');
        foreach ($chunks as $content) {
            GenerateChunkQuizJob::dispatch(
                $s, 
                $content, 
                3, 
                'gemma-3-12b-it-IQ4_XS',
                'español'
            );
        }
    }
    Session::flash('status', 'Tasks stored in the queue to generate the book questions');
}

Other considerations

You might need to modify other server parameters in your php.ini such as:

  • max_execution_time: It is likely set to 60. Change it to 0 (infinite) or 900.
  • memory_limit: If the AI model or text processing consumes a lot of RAM, the process dies. Set it to at least 512M.

All these changes are to avoid errors like:

INFO  Processing jobs from the [default] queue.  
App\Jobs\GenerateQuizJob ..... RUNNING
App\Jobs\GenerateQuizJob ..... 1m FAIL
zsh: killed     php artisan queue:work

You may notice that the work stops automatically after a minute or a fixed time; remember that AIs take a long time to respond, and if we add the prompt and the heavy task to this, we must maintain a balance between our computer's resources and the task we are performing.

The Potential of Tools in Laravel AI SDK

Tools are extremely powerful mechanisms that, when combined with agents and knowledge retrieval systems (such as RAG), allow us to create high-level personalized assistants. Although tools like OpenAI or Claude are the benchmark, implementing our own solutions gives us total control over routine tasks and data privacy.

With this ecosystem we can manage:

  • Persistent conversations: Save and retrieve previous contexts.
  • Multimodality: Generate images, audio, and transcriptions.
  • Connectivity: Allow the agent to interact with the outside world or with our own infrastructure.

What are Tools and why do we need them?

As the name indicates, Tools are utilities that provide extra functionality to our agents. We could see them as a middle point between a basic agent and an MCP (Model Context Protocol) system.

The problem of isolated LLMs

Most Large Language Models (LLMs), especially local ones, fail when asked for real-time information (weather, current date, news). Since they are not connected to the network by default, their knowledge is limited to their prior training.

Example of using a Tool:

If we enable a tool like WebSearch, the agent can consult the internet to answer what day it is today or what the current weather is. Without that "tool", the agent would simply admit its inability or hallucinate the answer:

app\Ai\Agents\Assistant.php

class Assistant implements Agent, HasTools
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return 'You are a helpful assistant. For URL requests, summarize the content provided by the user.';
    }

    public function tools(): iterable
    {
        return [
            new WebFetch,
        ];
    }
}

In this example IT IS AN EXISTING TOOL in Laravel, but we can create our own.

WebFetch: Allows the agent to search on Google or Bing to obtain updated context.

Technical Implementation in Laravel

Laravel facilitates the creation of custom tools through artisan commands.

Creating a Tool

To create a new tool, we use the command:

$ php artisan make:tool ToolName

Once created, the main structure is defined in the handle method, which is where the magic happens. Here we define the logic that the agent will execute when it decides it needs that specific tool.

Practical examples of Tools:

  • Calendar and Appointments: A personal assistant can use a Tool to read or write to a file (or database) and schedule meetings.
  • External Communication: A Tool can be created that uses the Telegram API so that the agent sends messages automatically upon finishing a task.

Practical Case: Appointment Agent (AppointmentAgent)

An example where we separate the logic into three pieces: the Command, the Agent, and the Tool.

  • The Tool (CreateAppointment): Defines a structured schema (date, message, creation date). It is responsible for verifying if the file exists in storage and recording the information persistently.
  • The Agent: Receives the instruction from the user and decides to invoke the appointment tool to save the information.

app\Ai\Tools\ScheduleAppointment.php

<?php

namespace App\Ai\Tools;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

class ScheduleAppointment implements Tool
{
    public function description(): Stringable|string
    {
        return 'Schedule an appointment by storing the date and message in a JSON file within the storage/app directory.';
    }

    public function handle(Request $request): Stringable|string
    {
        $data = [
            'date' => $request['date'],
            'message' => $request['message'],
            'created_at' => now()->toIso8601String(),
        ];

        $filename = storage_path('app/appointments/'.now()->format('Y-m-d_His').'.json');

        if (! is_dir(dirname($filename))) {
            mkdir(dirname($filename), 0755, true);
        }

        file_put_contents($filename, json_encode($data, JSON_PRETTY_PRINT));

        $relativePath = 'appointments/'.now()->format('Y-m-d_His').'.json';

        return "Appointment scheduled successfully. Date: {$data['date']}, Message: {$data['message']}. Stored in: {$relativePath}";
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'date' => $schema->string()->required()->description('The appointment date and time in any format.'),
            'message' => $schema->string()->required()->description('A brief description or note about the appointment.'),
        ];
    }
}

app\Ai\Agents\Appointment.php

<?php

namespace App\Ai\Agents;

use App\Ai\Tools\ScheduleAppointment;
use Laravel\Ai\Attributes\Model;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;

// #[Provider(Lab::Ollama)]
// #[Model('gemma3:1b')]
class Appointment implements Agent, HasTools
{
    use Promptable;

    public function instructions(): Stringable|string
    {
        return 'You are an appointment scheduling assistant. When the user provides a date and message, use the ScheduleAppointment tool to save it. Call the tool immediately with the provided date and message.';
    }

    public function tools(): iterable
    {
        return [
            new ScheduleAppointment,
        ];
    }
}

app\Console\Commands\Ai\AppointmentCommand.php

<?php

namespace App\Console\Commands\Ai;

use App\Ai\Agents\Appointment;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;

#[Signature('ai:appointment {action?}')]
#[Description('Schedule appointments or list them')]
class AppointmentCommand extends Command
{
    public function handle(): int
    {
        $action = $this->argument('action') ?? $this->choice(
            'Action',
            ['schedule', 'list'],
            'schedule'
        );

        if ($action === 'list') {
            $this->listAppointments();

            return Command::SUCCESS;
        }

        $date = $this->ask('Appointment date (e.g., 2026-05-15 14:00)');
        $message = $this->ask('Message');

        $this->info('Scheduling appointment...');

        $response = (new Appointment)->prompt("Schedule an appointment. Date: {$date}, Message: {$message}", model: 'gemma-3-12b-it-IQ4_XS');

        $this->info('Response:');
        $this->line($response->text);

        return Command::SUCCESS;
    }

    private function listAppointments(): void
    {
        $files = glob(storage_path('app/appointments/*.json'));

        if (empty($files)) {
            $this->warn('No appointments found.');

            return;
        }

        $this->table(['Date', 'Message', 'Created'], collect($files)->map(function ($file) {
            $data = json_decode(file_get_contents($file), true);

            return [$data['date'] ?? '-', $data['message'] ?? '-', $data['created_at'] ?? '-'];
        })->all());
    }
}

To use the agent above:

$ php artisan ai:appointment

>> action [schedule]:
>>   [0] schedule
>>   [1] list
  • schedule, allows you to register an appointment locally.
  • list, allows you to see current appointments.

Limitation in Local Environments

It is important to mention that when working with local LLMs (such as those managed through Jan.ai or Ollama), support for the use of Tools may be limited. In my tests, although the code is correct, some local models still fail to "decide" when to call the tool automatically, unlike cloud models such as GPT-4 or Gemini.

Introduction to Embeddings and Vector Databases

Up until now, we have worked with agents that process text chunks to generate responses. While functional, this method has limitations: if the data volume is very large, we exceed the AI's "context window" or require computing power that is unattainable for a local environment.

The next topic we are going to cover is one of the most interesting in the Laravel AI documentation: Embeddings. We have already seen how to work with agents and the use of context, but Embeddings take AI to a much more advanced conceptual and technical level.

Embeddings are the professional solution. An embedding transforms a fragment of text into a vector (a complex numerical matrix that represents the semantic meaning of the text). This allows us to perform intelligent searches and manage large volumes of information through Vector Databases.

The Problem of Limited Context

Recall that when working with agents (such as when generating questions for a book), the "context" is the piece of information we send to the AI. Ideally, we would send the entire book or a whole chapter, but we face two barriers:

  1. The context window: AIs have a limit on the amount of data they can process at once.
  2. Computing power: The more data we send, the more resources it consumes. When running local models, this can crash our machine.

What is an Embedding and a Vector Database?

In simple terms, an Embedding is the conversion of text into a set of vectors (an array of numbers). When we print an Embedding, we see a long list of decimal numbers representing the "semantic meaning" of that text for the AI.

To handle this information, a traditional database won't suffice. We need a Vector Database.

  • While in SQL we store strings, integers, or booleans, here we handle a special data type called a vector.
  • Currently, the most robust database with support for this is PostgreSQL through an extension called pgvector.

Necessary Tools

To implement this, we need three key components:

1. PostgreSQL with the pgvector extension

Unlike MySQL or MariaDB, PostgreSQL features pgvector, the only robust option that allows for native vector handling.

What it is: It is a special data type, a "vector column" that stores these numerical matrices.

Installation: You need to install PostgreSQL and enable the extension by running this statement in your database; you can use Dbngin and create a PSQL database directly from it.

Once created, run the following command:

$ CREATE EXTENSION IF NOT EXISTS vector;

And you should see something like:

Query 1 OK: CREATE EXTENSION

2. PHP Package for Laravel

Install the necessary driver to integrate PostgreSQL with AI functions:

$ composer require pgvector/pgvector-php # Or the official Laravel AI package required

And you configure your database:

.env

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=laravel
DB_USERNAME=postgres
DB_PASSWORD=postgres

3. Ollama (The local "Brain")

We need a local engine to run the Large Language Model (LLM) responsible for performing the text-to-vector conversion (Embedding). I have chosen Ollama for its stability, but you can check the official documentation (the table I copied earlier to show which LLM can be used for which operations) and use Gemini, OpenAI, or any that supports Embedding.

  • Download: ollama.com

Recommended model: We will use a specific model for embeddings, which is lightweight and efficient:

$ ollama pull nomic-embed-text

This model is small, fast, and has a context window of 8k tokens, ideal for processing large volumes of data.

You can use the command:

$ ollama list 
             
NAME                       ID              SIZE      MODIFIED   
nomic-embed-text:latest    0a109f422b47    274 MB    2 days ago    
andrescruz@Mac-mini-de-Andres larapackage % 

To see that it is active, and from the browser:

  • http://localhost:11434/

4. Configuration in Laravel

In your AI configuration file (config/ai.php or similar), you must define which model you will use for the embedding process:

Even if you already have a model configured for chat (like llama3 or gemma), for embeddings you can (and should) specify the Ollama model:

config\ai.php

'default' => 'openai',
***
'default_for_embeddings' => 'ollama',

What is a Vector Column?

In a traditional implementation, we have normal columns: ID, text, dates. However, in a vector database, a special data type appears: the vector:

Schema::create('documents', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->text('content');
    // $table->vector('embedding', dimensions: 1536)->index(); // OPEN AI
    $table->vector('embedding', dimensions: 768)->index(); // Ollama
    $table->timestamps();
});

You can imagine a vector as a cloud of points in a multidimensional space. While we are used to two dimensions (X, Y) or three (X, Y, Z), Large Language Models (LLMs) handle hundreds or thousands of dimensions.

  • OpenAI: Uses about 1,536 dimensions.
  • Nomic Embed Text (Local): Uses about 768 dimensions.

If you do NOT enter the correct dimensions, you will get an error like the following:

SQLSTATE[22000]: Data exception: 7 ERROR: expected 1536 dimensions, not 768 (Connection: pgsql, Host: 127.0.0.1, Port: 5432, Database: laravel, SQL: update "documents" set "embedding" =

Ultimately, this column, when we perform the embedding process, will look something like this:

use Laravel\Ai\Embeddings;
 
$response = Embeddings::for([
    'Napa Valley has great wine.',
    'Laravel is a PHP framework.',
])->generate();
 
$response->embeddings; // [[0.123, 0.456, ...], [0.789, 0.012, ...]]

This "matrix" of numbers is the format that the AI understands directly. Unlike a normal database where you can manually enter and edit text, a vector is an abstract representation of the meaning of that text.

Warning: Context Management and Data Limits

When working with an LLM (Large Language Model), we must remember that the "context window" is not infinite. If you try to pass too much information at once, the system will fail. For example:

Like in our Seeder, where we have just 5 records with little text. The model supports this perfectly.

Risk Scenario: If you had 1,000 records, each with 10,000 characters. In this case, the LLM will fail because you will exceed the supported token limit, whether you are working with a local model or one in the cloud.

How to fix it?

If you find yourself dealing with a large volume of data, the logical solution is not to send the entire block at once. The ideal approach is to place the logic inside a foreach loop and iterate through the records one by one (or in small groups), depending on what the model supports.

This is a fundamental technical detail you must keep in mind when scaling your application so that it does not fail when processing large amounts of information.

Semantic Proximity

What really interests us for our exercises is searching for proximity between texts. An AI model, having been trained on millions of data points, already "knows" which words or concepts are related.

For example, the terms "dog", "bark", and "veterinarian" will be represented by points very close together in that vector cloud. In contrast, "dog" and "planet" will be very far apart. This proximity is measured mathematically using algorithms such as Cosine Similarity or Euclidean Distance.

  • Similarity (Cosine Similarity): Measured from 0 to 1. If it is 1, the texts are identical; if it is 0, they have nothing to do with each other.
  • Distance (Euclidean Distance): Measured from 0 upwards. If the distance is 0, the points are the same.

How Data is Processed: From Text to Vector

To convert text into a numerical matrix that we can store in PostgreSQL, the AI follows these steps:

  • Tokenization: Breaks the text into small pieces called tokens.
  • Neural Processing: Tokens pass through layers of a neural network.
  • Output: The model returns a list of decimal numbers (the vector).

It is like a kind of "complex binary". Although we cannot read it, for the AI, it is its way of indexing knowledge to retrieve it extremely quickly via a SQL query.

To generate this dimension matrix, you absolutely must use an LLM (Linear Management Model) to do the work; that is, PostgreSQL is the storage and the LLM is the brain that allows you to generate this structure.

Now, let's generate the test data:

database\seeders\DocumentSeeder.php

<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class DocumentSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $documents = [
            ['title' => 'Napa Valley Wineries', 'content' => 'Napa Valley is famous for its world-class wineries and premium wine production. The region offers tasting rooms, vineyard tours, and stunning views.'],
            ['title' => 'Laravel Tutorial', 'content' => 'Laravel is a PHP framework known for its elegant syntax and developer-friendly features. It provides routing, authentication, and caching out of the box.'],
            ['title' => 'React.js Guide', 'content' => 'React is a JavaScript library for building user interfaces. It uses a component-based architecture and virtual DOM for efficient rendering.'],
            ['title' => 'Wine Tasting Tips', 'content' => 'When tasting wine, look at the color, smell the aroma, and savor the flavor. Start with white wines and move to reds for the best experience.'],
            ['title' => 'PHP Best Practices', 'content' => 'PHP 8 introduced many new features like named arguments, attributes, and match expressions. Follow PSR standards for clean code.'],
        ];
        foreach ($documents as $doc) {
            DB::table('documents')->insert([
                'title' => $doc['title'],
                'content' => $doc['content'],
                'embedding' => null,
                'created_at' => now(),
                'updated_at' => now(),
            ]);
        }
    }
}

database\seeders\DatabaseSeeder.php

public function run(): void
{
    // User::factory(10)->create();
    // User::factory()->create([
    //     'name' => 'Test User',
    //     'email' => 'test@example.com',
    // ]);
    $this->call(DocumentSeeder::class);
}

And we execute:

$ php artisan migrate:fresh 
$ php artisan db:seed 

With the previous test data, you will see that the embedding column, the vector one, is NOT filled, since it is not flat content. To fill it, we must use the AI ​​that we configured earlier:

app\Http\Controllers\EmbeddingTestController.php

public function generateEmbeddings(): JsonResponse
{
    $documents = Document::all();
    $contents = $documents->pluck('content')->toArray();
    $response = Embeddings::for($contents)->generate();
    foreach ($documents as $index => $document) {
        $document->embedding = $response->embeddings[$index];
        $document->save();
    }
    return response()->json([
        'message' => 'Embeddings generated via AI SDK',
        'method' => 'php-ai-sdk',
        'count' => count($response->embeddings),
    ]);
}

routes/web.php

Route::get('/embeddings/generate', [EmbeddingTestController::class, 'generateEmbeddings']);

A query method to view the listed data:

app\Http\Controllers\EmbeddingTestController.php

public function listDocuments(): JsonResponse
    {
        $documents = Document::all(['id', 'title', 'content', 'embedding']);
        return response()->json([
            'documents' => $documents,
            'count' => $documents->count(),
        ]);
    }

routes/web.php

Route::get('/embeddings/list', [EmbeddingTestController::class, 'listDocuments']);

Learn how to connect Gemini, ChatGTP, or an LLM locally from your Laravel app. We'll cover installation, usage, creating schematics, use cases, and configuration for real-world projects.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.

Andrés Cruz

ES En español