DEV Community

Cover image for 7 Production Patterns for Building Reliable AI Agents in Laravel
Hossein Hezami
Hossein Hezami

Posted on

7 Production Patterns for Building Reliable AI Agents in Laravel

A reliable AI agent is not the one that never fails. It is the one whose failures are boring: logged, bounded, recoverable, and easy to explain.

The unsafe version is the one that silently retries a failed tool call, mutates a record, sends an email, exceeds its budget, and leaves no useful trail.

Laravel is a good place to build production-grade AI agents because the framework already gives you the unglamorous parts of production systems: queues, validation, authorization, database-backed state, rate limiting, events, logging, and testing. The trick is to stop treating the agent like a chat prompt and start treating it like a supervised workflow.

Here are seven production patterns that make AI agents in Laravel more reliable.

TL;DR

  • Persist every agent run as a database-backed workflow.
  • Move agent execution out of the HTTP request cycle.
  • Give every tool a strict contract and risk level.
  • Validate all model output before acting on it.
  • Assemble context with budgets and redaction.
  • Require human approval for destructive or expensive actions.
  • Use observability and evals before changing prompts or models.

📋 Table of Contents

1. The Agent Run Record Pattern

Scenario:

Your agent starts processing a support ticket. It reads the ticket, identifies the customer, calls a CRM tool, and then fails halfway through. Nobody knows what it already did. Did it add a note? Did it send a reply? Should the whole run restart?

Why it matters:

If your agent's state exists only in memory, in a prompt, or in a temporary controller variable, you cannot recover from failure. You also cannot audit, replay, rate-limit, or debug the run properly.

Solution:

Model every agent execution as a persisted record.

<?php

namespace App\Agents;

enum AgentRunStatus: string
{
    case Pending = 'pending';
    case Running = 'running';
    case WaitingApproval = 'waiting_approval';
    case Completed = 'completed';
    case Failed = 'failed';
}
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Models;

use App\Agents\AgentRunStatus;
use Illuminate\Database\Eloquent\Model;

class AgentRun extends Model
{
    protected $fillable = [
        'user_id',
        'agent',
        'status',
        'task',
        'input',
        'context_snapshot',
        'budget',
        'result',
        'error',
        'started_at',
        'finished_at',
    ];

    protected function casts(): array
    {
        return [
            'status' => AgentRunStatus::class,
            'input' => 'array',
            'context_snapshot' => 'array',
            'budget' => 'array',
            'result' => 'array',
            'error' => 'array',
            'started_at' => 'datetime',
            'finished_at' => 'datetime',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

The exact fields depend on your use case, but I would usually want:

  • Who started the run
  • Which agent configuration was used
  • What task was requested
  • What input triggered the run
  • What context was included
  • What budget applied
  • What tools were attempted
  • What the final result or failure was

Why this works:

The agent run becomes an operational object. You can query it, display it, resume it, cancel it, and audit it.

For example, you can easily find stuck runs:

AgentRun::query()
    ->where('status', AgentRunStatus::Running)
    ->where('started_at', '<', now()->subMinutes(10))
    ->get();
Enter fullscreen mode Exit fullscreen mode

That one query becomes an operational dashboard, a cleanup job, or an alert.

💡 Practical note: Do not store raw secrets, API keys, or unnecessary PII in the run record. Store identifiers, references, and redacted summaries instead.

2. The Queued Execution Pattern

Scenario:

A controller receives a request, calls an LLM, waits for a response, calls two tools, waits again, and then returns. The user refreshes the page. Now the same expensive process starts twice.

Why it matters:

Agent execution is often slow, expensive, and stateful. It does not belong in the normal request/response cycle unless the interaction is deliberately synchronous, short-lived, and read-only.

Solution:

Use Laravel queues.

The controller should create the run, dispatch a job, and return quickly:

<?php

namespace App\Http\Controllers;

use App\Agents\AgentRunStatus;
use App\Jobs\ExecuteAgentRun;
use App\Models\AgentRun;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class StartSupportAgentController extends Controller
{
    public function __invoke(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'ticket_id' => ['required', 'exists:tickets,id'],
            'task' => ['required', 'string', 'max:2000'],
        ]);

        $run = AgentRun::create([
            'user_id' => $request->user()->id,
            'agent' => 'support_triage',
            'status' => AgentRunStatus::Pending,
            'task' => $validated['task'],
            'input' => [
                'ticket_id' => $validated['ticket_id'],
            ],
            'budget' => [
                'max_steps' => 6,
                'max_tool_calls' => 8,
                'max_seconds' => 90,
            ],
        ]);

        ExecuteAgentRun::dispatch($run)->onQueue('agents');

        return response()->json([
            'run_id' => $run->id,
            'status' => $run->status->value,
        ], 202);
    }
}
Enter fullscreen mode Exit fullscreen mode

Then do the work in a queued job:

<?php

namespace App\Jobs;

use App\Agents\AgentExecutor;
use App\Agents\AgentRunStatus;
use App\Models\AgentRun;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;

class ExecuteAgentRun implements ShouldQueue, ShouldBeUnique
{
    use Dispatchable;
    use InteractsWithQueue;
    use Queueable;
    use SerializesModels;

    public int $timeout = 120;

    public int $tries = 1;

    public function __construct(
        public AgentRun $run,
    ) {}

    public function uniqueId(): string
    {
        return 'agent-run:'.$this->run->getKey();
    }

    public function uniqueFor(): int
    {
        return 300;
    }

    public function handle(AgentExecutor $executor): void
    {
        $executor->execute($this->run);
    }

    public function failed(Throwable $exception): void
    {
        $this->run->forceFill([
            'status' => AgentRunStatus::Failed,
            'error' => [
                'type' => class_basename($exception),
                'message' => $exception->getMessage(),
            ],
            'finished_at' => now(),
        ])->save();
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this works:

The HTTP layer remains fast. The agent gets a controlled execution environment with timeouts, queue isolation, and failure handling.

You can also scale the agent queue separately from your normal application queues:

php artisan queue:work --queue=agents --timeout=120
Enter fullscreen mode Exit fullscreen mode

⚠️ Gotcha: Be careful with retries. If the agent performs side effects, automatically retrying the whole job can duplicate actions. In many agent workflows, tries = 1 plus explicit recovery is safer than blind retries.

3. The Tool Contract Pattern

Scenario:

Your agent has a tool called update_customer. The model assumes it can update any customer field. It changes a billing email address when it only meant to add a support note.

Why it matters:

The model should not decide what a tool means or what it is allowed to do. Your application should.

Tools need contracts.

Solution:

Define a tool interface with a name, description, schema, risk level, and execution method.

<?php

namespace App\Agents\Tools;

enum ToolRisk: string
{
    case ReadOnly = 'read_only';
    case ReversibleWrite = 'reversible_write';
    case DestructiveWrite = 'destructive_write';
}
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Agents\Tools;

interface AgentTool
{
    public function name(): string;

    public function description(): string;

    public function schema(): array;

    public function risk(): ToolRisk;

    public function execute(array $input): ToolResult;
}
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Agents\Tools;

final readonly class ToolResult
{
    public function __construct(
        public bool $successful,
        public array $output = [],
        public ?string $error = null,
    ) {}

    public static function success(array $output = []): self
    {
        return new self(true, $output);
    }

    public static function failure(string $error): self
    {
        return new self(false, [], $error);
    }
}
Enter fullscreen mode Exit fullscreen mode

Then register tools in a registry:

<?php

namespace App\Agents\Tools;

use InvalidArgumentException;

final class ToolRegistry
{
    /**
     * @var array<string, AgentTool>
     */
    private array $tools = [];

    public function add(AgentTool $tool): void
    {
        $this->tools[$tool->name()] = $tool;
    }

    public function get(string $name): AgentTool
    {
        if (! isset($this->tools[$name])) {
            throw new InvalidArgumentException("Unknown tool: {$name}");
        }

        return $this->tools[$name];
    }

    /**
     * @return array<int, array{name: string, description: string, parameters: array}>
     */
    public function definitions(): array
    {
        return array_map(
            fn (AgentTool $tool) => [
                'name' => $tool->name(),
                'description' => $tool->description(),
                'parameters' => $tool->schema(),
            ],
            array_values($this->tools),
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this works:

The model receives a structured tool catalog instead of a set of ad hoc functions. Your application can also enforce rules based on the tool's risk level.

The description matters more than people expect. Compare these:

Updates a customer.
Enter fullscreen mode Exit fullscreen mode

and:

Updates only the customer's support preferences.
Does not modify billing, subscription, login, or ownership fields.
Requires a valid customer UUID.
Enter fullscreen mode Exit fullscreen mode

The second one gives the model a much smaller space in which to be wrong.

4. The Validated Output Pattern

Scenario:

You ask the model to return JSON. It responds with:

Here is the result:

{"action": "reply", "message": "Thanks for reaching out..."}
Enter fullscreen mode Exit fullscreen mode

Your code tries to decode the entire string and fails. Or worse, it accepts partially valid output and uses it to call a tool.

Why it matters:

Model output is not a trusted API response. It is generated text. It may include markdown, prose, truncated JSON, wrong types, or fields that violate your business rules.

Solution:

Parse defensively, then validate with Laravel's validator.

<?php

namespace App\Agents\Support;

use RuntimeException;

final class JsonBlockParser
{
    public function parse(string $raw): array
    {
        $start = strpos($raw, '{');
        $end = strrpos($raw, '}');

        if ($start === false || $end === false || $end <= $start) {
            throw new RuntimeException('No JSON object found in model output.');
        }

        $json = substr($raw, $start, ($end - $start) + 1);

        $decoded = json_decode(
            $json,
            true,
            512,
            JSON_THROW_ON_ERROR,
        );

        if (! is_array($decoded)) {
            throw new RuntimeException('Decoded JSON was not an object.');
        }

        return $decoded;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then validate the parsed structure before using it:

$parser = new JsonBlockParser();

$parsed = $parser->parse($modelOutput);

$validated = validator($parsed, [
    'action' => ['required', 'in:reply,escalate,request_more_information'],
    'confidence' => ['required', 'numeric', 'between:0,1'],
    'message' => ['nullable', 'string', 'max:4000'],
    'escalation_reason' => [
        'required_if:action,escalate',
        'nullable',
        'string',
        'max:500',
    ],
])->validate();
Enter fullscreen mode Exit fullscreen mode

If validation fails, the agent should not silently improvise. It should either retry with a stricter instruction, fall back to a safe response, or route the task to a human.

Why this works:

The rest of your application never sees raw model text. It only sees data that passed a schema.

The same applies to tool-call arguments. If the model proposes:

{
  "tool": "refund_payment",
  "input": {
    "order_id": "12345",
    "amount_cents": -1000
  }
}
Enter fullscreen mode Exit fullscreen mode

your validation layer should reject that before the tool is executed.

🚨 Production warning: Never execute code, SQL, shell commands, or template output generated directly by the model without a strict validation and execution boundary.

5. The Context Budget Pattern

Scenario:

Your agent gives bad answers, so the team adds more context: the full ticket history, the entire policy document, recent orders, account notes, and a few logs. Now the model misses the one sentence that actually matters.

Why it matters:

More context is not the same as better context. Large prompts increase cost and latency, and they can make the model focus on irrelevant information.

Solution:

Treat context assembly as a ranking and budgeting problem.

<?php

namespace App\Agents\Context;

final readonly class ContextSection
{
    public function __construct(
        public string $name,
        public string $content,
        public int $priority,
        public bool $sensitive = false,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Agents\Context;

final class Redactor
{
    public function redact(string $text): string
    {
        $redacted = preg_replace(
            '/\b[\w.+-]+@[\w-]+\.[\w.]+\b/',
            '[email]',
            $text,
        );

        return $redacted ?? $text;
    }
}
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Agents\Context;

final class ContextAssembler
{
    public function __construct(
        private readonly Redactor $redactor,
        private readonly int $maxChars = 12000,
    ) {}

    /**
     * @param array<ContextSection> $sections
     */
    public function assemble(array $sections): string
    {
        usort(
            $sections,
            fn (ContextSection $a, ContextSection $b) => $b->priority <=> $a->priority,
        );

        $context = '';

        foreach ($sections as $section) {
            if ($section->sensitive) {
                continue;
            }

            $content = $this->redactor->redact($section->content);

            $candidate = trim($context."\n\n### {$section->name}\n".$content);

            if (mb_strlen($candidate) > $this->maxChars) {
                continue;
            }

            $context = $candidate;
        }

        return $context;
    }
}
Enter fullscreen mode Exit fullscreen mode

This uses character length as a simple budget. In a real system, you may want a better token estimate, but the architectural idea is the same: context should be selected deliberately.

Useful context sections usually include:

  • The task
  • The current record state
  • Relevant policy excerpts
  • Recent tool outputs
  • Constraints and output format

Usually unnecessary:

  • Full unrelated history
  • Every database column
  • Raw logs
  • Secrets
  • Internal notes with no bearing on the task

Why this works:

The agent receives a curated briefing instead of a data dump. That improves grounding and reduces the chance of acting on stale or irrelevant information.

6. The Approval Gate Pattern

Scenario:

The agent decides a customer deserves a refund. It calls the refund tool. The refund succeeds, but the ticket was actually about a duplicate charge that needed finance review.

Why it matters:

Some actions are too expensive, too irreversible, or too sensitive to execute automatically.

Approval gates are not a limitation. They are a production feature.

Solution:

Use the tool's risk level to decide whether the run can continue.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class ToolApproval extends Model
{
    protected $fillable = [
        'agent_run_id',
        'tool_name',
        'input',
        'status',
        'reviewed_by',
        'reviewed_at',
        'expires_at',
    ];

    protected function casts(): array
    {
        return [
            'input' => 'array',
            'reviewed_at' => 'datetime',
            'expires_at' => 'datetime',
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Inside the executor:

if ($tool->risk() === ToolRisk::DestructiveWrite) {
    ToolApproval::create([
        'agent_run_id' => $run->id,
        'tool_name' => $tool->name(),
        'input' => $validatedInput,
        'status' => 'pending',
        'expires_at' => now()->addHours(4),
    ]);

    $run->forceFill([
        'status' => AgentRunStatus::WaitingApproval,
    ])->save();

    return;
}
Enter fullscreen mode Exit fullscreen mode

Later, a human approves the action, and a separate job executes only that approved tool call:

class ExecuteApprovedTool
{
    public function handle(ToolApproval $approval): void
    {
        if ($approval->status !== 'approved') {
            throw new RuntimeException('Approval is not approved.');
        }

        if ($approval->expires_at->isPast()) {
            $approval->update(['status' => 'expired']);

            throw new RuntimeException('Approval expired.');
        }

        $tool = app(ToolRegistry::class)->get($approval->tool_name);

        $result = $tool->execute($approval->input);

        $approval->update([
            'status' => $result->successful ? 'executed' : 'failed',
            'executed_at' => now(),
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Why this works:

The model can propose an action, but it cannot finalize dangerous actions by itself. The approval record becomes an audit trail.

A useful risk matrix looks like this:

Tool risk Example Default policy
Read-only Fetch order Allowed if authorized
Reversible write Add internal note Allowed, logged
External notification Email customer Context-dependent approval
Financial action Refund payment Approval required
Destructive write Delete account Approval required or disabled

7. The Eval and Observability Pattern

Scenario:

You improve the system prompt. The agent sounds better in manual testing. Two days later, support notices that harmless billing questions are being escalated unnecessarily.

Why it matters:

Prompt changes are behavior changes. If you cannot test them, you are deploying guesses.

Solution:

Build evals and observability into the agent from the beginning.

Start with structured logging:

use Illuminate\Support\Facades\Log;

Log::channel('agent')->info('agent.tool_called', [
    'run_id' => $run->id,
    'tool' => $tool->name(),
    'risk' => $tool->risk()->value,
    'successful' => $result->successful,
]);
Enter fullscreen mode Exit fullscreen mode

Then add tests around agent behavior. You do not want to hit a real LLM provider for every test, so use a fake client.

<?php

namespace Tests\Agents;

use App\Agents\AgentRunStatus;
use App\Agents\Contracts\LlmClient;
use App\Jobs\ExecuteAgentRun;
use App\Models\AgentRun;

it('marks the run as waiting approval when a destructive tool is proposed', function () {
    $this->app->instance(LlmClient::class, new FakeLlmClient([
        json_encode([
            'tool' => 'refund_payment',
            'input' => [
                'order_id' => 'ord_123456789',
                'amount_cents' => 2500,
                'idempotency_key' => 'run_1_refund_attempt_1',
            ],
        ]),
    ]));

    $run = AgentRun::factory()->create([
        'status' => AgentRunStatus::Running,
        'agent' => 'support_triage',
    ]);

    ExecuteAgentRun::dispatchSync($run);

    expect($run->refresh()->status)->toBe(AgentRunStatus::WaitingApproval);
});
Enter fullscreen mode Exit fullscreen mode

Your eval suite should include more than happy paths. Test:

  • Ambiguous user requests
  • Missing required input
  • Malformed model JSON
  • Unauthorized tool calls
  • Destructive tool proposals
  • Provider timeouts
  • Budget exhaustion
  • Duplicate executions
  • Prompt injection attempts

Shadow mode is also valuable. Run the new agent version alongside the old process, but do not let it take real actions. Compare what it would have done with what humans actually did.

This is especially useful for:

  • Support triage
  • Ticket routing
  • Draft replies
  • Refund eligibility suggestions
  • Internal operations recommendations

Why this works:

You can detect regressions before customers feel them. Observability gives you the evidence to improve the agent instead of guessing.

🔍 Why this matters: An agent without evals is not a system. It is a prompt with production access.

Pattern Comparison

Each pattern solves a different failure mode.

Pattern Main failure it prevents Complexity Essential when
Agent run record Lost state, no audit trail Low Always
Queued execution Timeouts, duplicate runs Medium Multi-step agents
Tool contract Ambiguous or unsafe tool use Medium Any tool-calling agent
Validated output Malformed model responses Low Always
Context budget Irrelevant context and cost creep Medium Retrieval-heavy agents
Approval gate Dangerous autonomous actions Medium-high Money, deletion, external messages
Eval and observability Silent regressions Medium-high Production usage

If you only have time to implement three immediately, start with:

  1. Agent run records
  2. Validated output
  3. Tool risk levels with approval gates

Those three alone prevent a large class of embarrassing and expensive failures.

What I Would Require Before Shipping

Before allowing a Laravel-based AI agent to touch production data, I would want these boxes checked.

State

  • [ ] Every run has a database record.
  • [ ] Runs have explicit statuses.
  • [ ] Stuck runs can be detected.
  • [ ] Failed runs preserve enough context to debug safely.

Execution

  • [ ] Agent execution runs in queued jobs.
  • [ ] Timeouts are configured.
  • [ ] Retries are deliberate, not accidental.
  • [ ] Duplicate runs are prevented or safely idempotent.

Tools

  • [ ] Every tool has a schema.
  • [ ] Every tool declares its risk level.
  • [ ] Authorization is enforced in application code.
  • [ ] Destructive tools require approval or are disabled.

Output

  • [ ] Model output is parsed defensively.
  • [ ] Structured output is validated before use.
  • [ ] Invalid output has a safe fallback.
  • [ ] Raw model text is never executed directly.

Context

  • [ ] Context is selected by relevance.
  • [ ] Sensitive data is redacted.
  • [ ] Context size is bounded.
  • [ ] Untrusted content is separated from action tools.

Operations

  • [ ] Tool calls are logged.
  • [ ] Budgets are enforced.
  • [ ] Rate limits exist.
  • [ ] Failure routes to a human or review queue when needed.
  • [ ] Prompt changes are tested.

The goal is not to make the agent perfect. That is not possible.

The goal is to make the agent legible: bounded, observable, testable, and safe to fail.

Laravel gives you most of the tools to do that. The difference between a fragile agent and a production-ready one is whether you use them.

Top comments (0)