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
- 2. The Queued Execution Pattern
- 3. The Tool Contract Pattern
- 4. The Validated Output Pattern
- 5. The Context Budget Pattern
- 6. The Approval Gate Pattern
- 7. The Eval and Observability Pattern
- Pattern Comparison
- What I Would Require Before Shipping
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';
}
<?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',
];
}
}
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();
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);
}
}
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();
}
}
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
⚠️ Gotcha: Be careful with retries. If the agent performs side effects, automatically retrying the whole job can duplicate actions. In many agent workflows,
tries = 1plus 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';
}
<?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;
}
<?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);
}
}
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),
);
}
}
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.
and:
Updates only the customer's support preferences.
Does not modify billing, subscription, login, or ownership fields.
Requires a valid customer UUID.
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..."}
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;
}
}
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();
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
}
}
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,
) {}
}
<?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;
}
}
<?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;
}
}
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',
];
}
}
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;
}
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(),
]);
}
}
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,
]);
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);
});
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:
- Agent run records
- Validated output
- 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)