7 Production Patterns for Building Reliable AI Agents in Laravel
DEV Community

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 - 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. 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', ' 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: 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: 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 = 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. / 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 */ 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. 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. $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 irreversibl

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.