The first time an AI agent tells you, “I’ve refunded the order,” check the database.
If you have only built demo-style LLM integrations, that sentence sounds paranoid. If you have shipped one, it sounds obvious. The gap between a charming assistant and a production agent is not the model. It is the contract around the model: what it may return, what it may call, what Laravel will validate, and what the application will refuse to do.
A useful Laravel AI agent usually needs three things:
- Structured output — the model returns data your application can parse, not a cheerful paragraph.
- Tool calls — the model can request actions or fetch facts through controlled functions.
- Validation — every model-produced value is treated like hostile user input until proven safe.
This article builds that pattern with Laravel primitives: HTTP client, validation, enums, DTOs, repositories, and a controlled agent loop. The examples use an OpenAI-compatible tool-calling API, but the architecture is deliberately provider-agnostic. If you use Anthropic, Azure OpenAI, Mistral, or a local model with function calling, the shape changes; the discipline stays the same.
TL;DR
- Treat the model as an untrusted intern with good language skills and no business context.
- Define a final decision schema before writing the prompt.
- Use a
submit_decisiontool to force structured completion in multi-step agents. - Validate tool arguments twice: once as JSON Schema for the model, once with Laravel validation for your app.
- Cap agent steps, log tool calls, and make dangerous actions idempotent and policy-checked.
- Test with faked HTTP responses; do not rely on live model calls for regression tests.
📋 Table of Contents
- The real problem
- 1. Start with the decision contract, not the prompt
- 2. Structured output: make the model return data Laravel can parse
- 3. Tool calls: give the agent controlled hands
- 4. Validate model output like it is hostile user input
- 5. The agent loop: where tool calls become an actual workflow
- 6. Failure is normal: malformed JSON, timeouts, and partial tool results
- 7. Production guardrails: authorization, idempotency, and audit trails
- 8. Test the agent without paying for every edge case
- Choosing the right agent pattern
The real problem
A naive Laravel integration often looks like this:
$response = Http::withToken(config('services.openai.key'))
->post('https://api.openai.com/v1/chat/completions', [
'model' => config('agents.support.model'),
'messages' => [
['role' => 'system', 'content' => 'You are a helpful support agent.'],
['role' => 'user', 'content' => $customerMessage],
],
]);
return $response->json('choices.0.message.content');
That works until the model replies:
Sure! I checked the order. It looks like the package is delayed.
I think we should ask the customer to confirm their address.
Now what? Your application cannot safely act on “I think we should.” It needs a machine-readable decision:
{
"status": "needs_customer_info",
"confidence": 0.82,
"summary": "Order is delayed; carrier needs address confirmation.",
"suggested_reply": "Please confirm your shipping address.",
"actions": [
{
"type": "request_more_info",
"reason": "Carrier status says address confirmation required."
}
]
}
Once the model can request external facts, the problem gets harder. It may ask for an order. It may ask for a refund. It may hallucinate an order ID. It may call a tool with invalid arguments. It may call the same tool five times. It may produce valid JSON with invalid business values.
The solution is not a bigger prompt. The solution is an agent architecture with boundaries.
1. Start with the decision contract, not the prompt
Scenario:
You are building a support-triage agent. It receives a customer message and decides whether to answer, escalate, or ask for more information. The team starts by tuning the prompt: “Be concise,” “Only escalate when necessary,” “Return JSON.” The output improves, but it still drifts.
Why it matters:
Prompts are instructions to a probabilistic system. Contracts are constraints your application can enforce. If you do not know the exact shape of the decision, you cannot validate it, test it, or safely act on it.
Before writing the agent prompt, define the final decision as PHP types.
<?php
namespace App\Support\Agents;
enum AgentStatus: string
{
case Answered = 'answered';
case Escalated = 'escalated';
case NeedsInfo = 'needs_customer_info';
}
Then define a DTO for the final decision:
<?php
namespace App\Support\Agents;
final readonly class AgentDecision
{
public function __construct(
public AgentStatus $status,
public float $confidence,
public string $summary,
public string $suggestedReply,
public array $actions,
) {}
public static function fromArray(array $data): self
{
return new self(
status: AgentStatus::from($data['status']),
confidence: (float) $data['confidence'],
summary: $data['summary'],
suggestedReply: $data['suggested_reply'],
actions: $data['actions'],
);
}
}
And a result object for the whole agent run:
<?php
namespace App\Support\Agents;
final readonly class AgentResult
{
private function __construct(
public bool $successful,
public ?AgentDecision $decision,
public ?string $error,
public array $context = [],
) {}
public static function success(AgentDecision $decision): self
{
return new self(true, $decision, null);
}
public static function failure(string $error, array $context = []): self
{
return new self(false, null, $error, $context);
}
}
This may feel like boilerplate, but it changes the design. The agent is no longer “a chatbot.” It is a function with an input and a typed output:
AgentResult run(string $customerMessage, array $context = []);
Now you can ask useful questions:
- What statuses are allowed?
- What actions can the agent recommend?
- What confidence range is valid?
- What happens if the model is unsure?
- What is the maximum number of tool calls?
- Which tools are read-only, and which tools mutate state?
🧠 The important part: the contract comes before the model call. If you cannot write the DTO, you are not ready to write the agent.
2. Structured output: make the model return data Laravel can parse
There are two common ways to get structured output from an LLM:
- Ask the provider to constrain the final response with a JSON Schema.
- Force the model to call a “final decision” tool whose arguments match your schema.
For simple one-shot tasks, provider-native structured output is excellent. For agents with tool calls, I prefer the second approach: make the final decision itself a tool.
Why? Because multi-step agents often do not finish with a normal text response. They finish after several tool calls. A submit_decision tool gives the model a clear way to say, “I am done, and this is my final structured answer.” It also works across providers that implement tool calling differently.
Define the final decision tool:
private function submitDecisionDefinition(): array
{
return [
'type' => 'function',
'function' => [
'name' => 'submit_decision',
'description' => 'Submit the final decision for this customer request.',
'parameters' => [
'type' => 'object',
'properties' => [
'status' => [
'type' => 'string',
'enum' => [
'answered',
'escalated',
'needs_customer_info',
],
],
'confidence' => [
'type' => 'number',
],
'summary' => [
'type' => 'string',
'maxLength' => 500,
],
'suggested_reply' => [
'type' => 'string',
'maxLength' => 2000,
],
'actions' => [
'type' => 'array',
'maxItems' => 3,
'items' => [
'type' => 'object',
'properties' => [
'type' => [
'type' => 'string',
'enum' => [
'send_reply',
'escalate_to_human',
'request_more_info',
],
],
'reason' => [
'type' => 'string',
'maxLength' => 300,
],
],
'required' => ['type', 'reason'],
'additionalProperties' => false,
],
],
],
'required' => [
'status',
'confidence',
'summary',
'suggested_reply',
'actions',
],
'additionalProperties' => false,
],
],
];
}
If your provider supports strict schema mode for tools or structured outputs, use it. But do not make that your only protection. Provider-side schema enforcement reduces malformed output; it does not replace server-side validation.
A useful mental model:
- JSON Schema for the model: helps the model produce the right shape.
- Laravel validation for the application: decides whether the shape is acceptable.
- Domain rules for the business: decide whether the validated shape is allowed.
Those are three different layers. Do not collapse them.
For a one-shot extraction task, you might use provider-native structured output directly:
$response = Http::withToken(config('services.openai.key'))
->post('https://api.openai.com/v1/chat/completions', [
'model' => config('agents.support.model'),
'messages' => $messages,
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'support_ticket_classification',
'schema' => [
'type' => 'object',
'properties' => [
'category' => ['type' => 'string'],
'priority' => ['type' => 'string', 'enum' => ['low', 'medium', 'high']],
],
'required' => ['category', 'priority'],
'additionalProperties' => false,
],
],
],
]);
That pattern is great for classification, extraction, summarization, and routing. But once the model needs to call tools, gather data, and decide when it is finished, the submit_decision tool becomes more robust.
💡 Practical note: if your agent sometimes replies with plain text instead of calling the final tool, do not parse the text as a fallback. Send another instruction telling it to finish with
submit_decision. Text parsing is where production agents quietly rot.
3. Tool calls: give the agent controlled hands
An agent without tools is just a classifier with good manners. Tools let it fetch order data, search internal documentation, check subscription status, or create a support ticket.
But tools are also the place where demos become dangerous. The model should never directly touch Eloquent models, payment gateways, or infrastructure. It should call narrow application-level functions that validate input, enforce authorization, and return only the data the agent needs.
Define a tool interface:
<?php
namespace App\Support\Agents\Tools;
interface AgentTool
{
public function name(): string;
public function description(): string;
/**
* JSON Schema exposed to the model.
*/
public function parameters(): array;
/**
* Laravel validation rules enforced by the application.
*/
public function rules(): array;
public function handle(array $arguments): array;
}
Notice the separation:
parameters()describes the tool to the model.rules()protects your application.handle()executes only after validation.
Here is a read-only tool that fetches an order:
<?php
namespace App\Support\Agents\Tools;
use App\Repositories\OrderRepository;
use App\Models\OrderItem;
final class GetOrderTool implements AgentTool
{
public function __construct(
private OrderRepository $orders,
) {}
public function name(): string
{
return 'get_order';
}
public function description(): string
{
return 'Fetch an order by its public order ID. Use this before making any order-related decision.';
}
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'order_id' => [
'type' => 'string',
'description' => 'Public order ID, e.g. ORD-9F2K1A.',
],
],
'required' => ['order_id'],
'additionalProperties' => false,
];
}
public function rules(): array
{
return [
'order_id' => ['required', 'string', 'regex:/^[A-Z0-9-]{6,32}$/'],
];
}
public function handle(array $arguments): array
{
$order = $this->orders->findByPublicId($arguments['order_id']);
if (! $order) {
return [
'ok' => false,
'error' => 'Order not found.',
];
}
return [
'ok' => true,
'order' => [
'id' => $order->public_id,
'status' => $order->status->value,
'total_cents' => $order->total_cents,
'currency' => $order->currency,
'items' => $order->items->map(fn (OrderItem $item) => [
'sku' => $item->sku,
'name' => $item->name,
'quantity' => $item->quantity,
])->values()->all(),
],
];
}
}
The tool returns a small, deliberate payload. It does not return the full Eloquent model. It does not return internal IDs unless the agent needs them. It does not return customer payment details. It returns exactly enough information for the agent to make the next decision.
Now register tools in a registry:
<?php
namespace App\Support\Agents\Tools;
use Illuminate\Support\Facades\Validator;
use Throwable;
final class ToolRegistry
{
/** @var array<string, AgentTool> */
private array $tools = [];
public function add(AgentTool $tool): void
{
$this->tools[$tool->name()] = $tool;
}
public function definitions(): array
{
return collect($this->tools)
->map(fn (AgentTool $tool) => [
'type' => 'function',
'function' => [
'name' => $tool->name(),
'description' => $tool->description(),
'parameters' => $tool->parameters(),
],
])
->values()
->all();
}
public function execute(string $name, array $arguments): array
{
$tool = $this->tools[$name] ?? null;
if (! $tool) {
return [
'ok' => false,
'error' => "Unknown tool: {$name}",
];
}
$validator = Validator::make($arguments, $tool->rules());
if ($validator->fails()) {
return [
'ok' => false,
'error' => 'Invalid tool arguments.',
'details' => $validator->errors()->toArray(),
];
}
try {
return $tool->handle($validator->validated());
} catch (Throwable $e) {
report($e);
return [
'ok' => false,
'error' => 'Tool execution failed. Try again with corrected arguments.',
];
}
}
}
The registry is the firewall between the model and your application.
⚠️ Gotcha: tool descriptions matter. If your agent calls the wrong tool or invents arguments, the problem is often not the model. It is a vague tool description, overlapping tools, or a schema that allows too much.
4. Validate model output like it is hostile user input
A common mistake is validating only the JSON shape and then trusting the values.
This is valid JSON:
{
"status": "escalated",
"confidence": 47,
"summary": "Customer is upset.",
"suggested_reply": "",
"actions": [
{
"type": "delete_all_tickets",
"reason": "Because."
}
]
}
It is still garbage.
Laravel’s validator is a natural fit here because you probably already use it for forms, APIs, and webhooks. Create a dedicated validator for the final decision:
<?php
namespace App\Support\Agents;
use Illuminate\Validation\Rule;
final class DecisionValidator
{
public function rules(): array
{
return [
'status' => ['required', Rule::enum(AgentStatus::class)],
'confidence' => ['required', 'numeric', 'between:0,1'],
'summary' => ['required', 'string', 'max:500'],
'suggested_reply' => ['required', 'string', 'max:2000'],
'actions' => ['required', 'array', 'max:3'],
'actions.*.type' => [
'required',
'string',
Rule::in([
'send_reply',
'escalate_to_human',
'request_more_info',
]),
],
'actions.*.reason' => ['required', 'string', 'max:300'],
];
}
}
Then use it when the model calls submit_decision:
private function finish(array $arguments): AgentResult
{
$validator = Validator::make(
$arguments,
app(DecisionValidator::class)->rules(),
);
if ($validator->fails()) {
return AgentResult::failure(
'The model produced an invalid final decision.',
$validator->errors()->toArray(),
);
}
return AgentResult::success(
AgentDecision::fromArray($validator->validated())
);
}
Validation should happen at several boundaries:
- Tool name validation — does this tool exist for this agent?
- Tool argument validation — are the arguments syntactically valid?
- Business validation — does this user, tenant, or conversation have permission?
- Final decision validation — is the structured output acceptable?
- Action execution validation — is the application allowed to perform the resulting side effect?
This layered approach is not paranoia. It is the same discipline you would apply to a public API. The model is an API client that occasionally hallucinates.
One subtle detail: return validation failures to the model as tool results when recovery is possible.
For example:
{
"ok": false,
"error": "Invalid tool arguments.",
"details": {
"order_id": ["The order id format is invalid."]
}
}
The model can often correct itself:
Please call get_order again with a valid order_id matching ORD-XXXXXX.
But do not loop forever. Put a retry cap around model corrections, and distinguish recoverable validation errors from hard failures.
5. The agent loop: where tool calls become an actual workflow
The agent loop is the part people underestimate. It looks simple:
- Send messages to the model.
- If the model calls tools, execute them.
- Append tool results.
- Repeat until the model submits a final decision.
In production, the loop needs limits.
A practical runner might look like this:
<?php
namespace App\Support\Agents;
use App\Support\Agents\Tools\ToolRegistry;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
use Throwable;
final class SupportAgent
{
public function __construct(
private ToolRegistry $tools,
private DecisionValidator $decisionValidator,
) {}
public function run(string $customerMessage, array $context = []): AgentResult
{
$messages = $this->initialMessages($customerMessage, $context);
$maxSteps = (int) config('agents.support.max_steps', 6);
for ($step = 0; $step < $maxSteps; $step++) {
$response = $this->chat($messages);
$message = $response['choices'][0]['message'] ?? null;
if (! is_array($message)) {
return AgentResult::failure('Unexpected model response shape.');
}
$messages[] = $this->normalizeAssistantMessage($message);
$toolCalls = $message['tool_calls'] ?? [];
if ($toolCalls === []) {
$messages[] = [
'role' => 'user',
'content' => 'You must finish by calling submit_decision with valid arguments.',
];
continue;
}
foreach ($toolCalls as $toolCall) {
$name = $toolCall['function']['name'] ?? '';
$rawArguments = $toolCall['function']['arguments'] ?? '{}';
$toolCallId = $toolCall['id'] ?? 'call_'.$step;
$arguments = json_decode($rawArguments, true);
if (! is_array($arguments)) {
$messages[] = $this->toolMessage($toolCallId, [
'ok' => false,
'error' => 'Arguments were not valid JSON. Send valid JSON arguments.',
]);
continue;
}
if ($name === 'submit_decision') {
return $this->finish($arguments);
}
$result = $this->tools->execute($name, $arguments);
$messages[] = $this->toolMessage($toolCallId, $result);
}
}
return AgentResult::failure('The agent exceeded the maximum number of steps.');
}
private function finish(array $arguments): AgentResult
{
$validator = Validator::make($arguments, $this->decisionValidator->rules());
if ($validator->fails()) {
return AgentResult::failure(
'The model produced an invalid final decision.',
$validator->errors()->toArray(),
);
}
return AgentResult::success(
AgentDecision::fromArray($validator->validated())
);
}
private function chat(array $messages): array
{
$response = Http::withToken(config('services.openai.key'))
->timeout((int) config('agents.support.timeout', 45))
->post('https://api.openai.com/v1/chat/completions', [
'model' => config('agents.support.model'),
'temperature' => 0,
'messages' => $messages,
'tools' => array_merge(
$this->tools->definitions(),
[$this->submitDecisionDefinition()],
),
'tool_choice' => 'auto',
]);
if ($response->failed()) {
throw new ModelRequestFailed(
'Model request failed with status '.$response->status()
);
}
return $response->json();
}
private function initialMessages(string $customerMessage, array $context): array
{
return [
[
'role' => 'system',
'content' => implode("\n", [
'You are a support triage agent.',
'Use tools when you need facts.',
'Do not speculate about order details.',
'When you have enough information, call submit_decision.',
'If you cannot determine the correct status, use needs_customer_info or escalated.',
]),
],
[
'role' => 'user',
'content' => json_encode([
'customer_message' => $customerMessage,
'context' => $context,
], JSON_THROW_ON_ERROR),
],
];
}
private function normalizeAssistantMessage(array $message): array
{
return array_filter([
'role' => 'assistant',
'content' => $message['content'] ?? null,
'tool_calls' => $message['tool_calls'] ?? null,
], fn ($value) => $value !== null);
}
private function toolMessage(string $toolCallId, array $payload): array
{
return [
'role' => 'tool',
'tool_call_id' => $toolCallId,
'content' => json_encode($payload, JSON_THROW_ON_ERROR),
];
}
private function submitDecisionDefinition(): array
{
// Same schema shown earlier.
}
}
The important parts are not the HTTP details. They are the constraints:
max_stepsprevents infinite loops.- Empty
tool_callstriggers a corrective message instead of silent failure. - Invalid JSON arguments become a tool error, not an exception.
- The final decision is validated before becoming a DTO.
- The runner returns a typed
AgentResult.
You can make this more sophisticated:
- Track token usage per run.
- Store each step in a database table.
- Stop when the conversation exceeds a budget.
- Force
submit_decisionafter too many read-only tools. - Use a cheaper model for classification and a stronger model for final reasoning.
- Queue long-running agents instead of executing them inside a web request.
For anything user-facing, I would not run a multi-step agent synchronously in a normal HTTP request unless the expected latency is very low. A better shape is often:
AgentRun::dispatch($conversation, $customerMessage);
Then store the result and notify the UI through websockets, polling, or a queued broadcast.
🔍 Why this matters: an agent loop is a state machine, not a chat. If you model it as a chat, you will end up with conditionals scattered across prompts, controllers, and middleware. If you model it as a state machine, you can reason about transitions, limits, and failures.
6. Failure is normal: malformed JSON, timeouts, and partial tool results
Production agents fail in boring and irritating ways:
- The provider times out.
- The model returns malformed JSON arguments.
- The tool throws an exception.
- The tool returns too much data.
- The model calls an unknown tool.
- The model calls the right tool with the wrong tenant’s ID.
- The model asks for a refund when the order is already refunded.
- The model produces a final decision that passes JSON validation but violates business rules.
Design for these from the start.
Model HTTP failures
Do not blindly retry LLM requests. A retry can double cost and, if the agent has side effects, double damage. Use retries only for clearly transient failures and only when the operation is safe to repeat.
$response = Http::withToken(config('services.openai.key'))
->timeout(45)
->retry(
times: 2,
sleepMilliseconds: 500,
when: function (int $attempt, Throwable $exception) {
return $exception instanceof ConnectionException && $attempt < 3;
},
)
->post('https://api.openai.com/v1/chat/completions', $payload);
For rate limits, respect the provider’s guidance. If you receive a 429, do not hammer the API. Use Laravel’s rate limiter before the call:
use Illuminate\Support\Facades\RateLimiter;
$key = 'agent:'.$user->id;
if (RateLimiter::tooManyAttempts($key, perMinute: 10)) {
return AgentResult::failure('Agent rate limit exceeded.');
}
RateLimiter::hit($key, 60);
Tool failures
Tool failures should usually be returned to the model as structured errors:
return [
'ok' => false,
'error' => 'Order not found.',
];
But not every error should be exposed to the model. If a tool fails because of an authorization problem, be careful about leaking details. A generic error is often better:
return [
'ok' => false,
'error' => 'You are not allowed to access this resource.',
];
For infrastructure errors, report the exception internally and return a sanitized message.
try {
return $tool->handle($validator->validated());
} catch (Throwable $e) {
report($e);
return [
'ok' => false,
'error' => 'Tool execution failed. Try again with corrected arguments.',
];
}
Partial results
Sometimes a tool can only partially succeed. For example, an order lookup may find the order but fail to fetch carrier tracking. Return the partial result and mark the missing part:
return [
'ok' => true,
'order' => [
'id' => $order->public_id,
'status' => $order->status->value,
'tracking' => [
'available' => false,
'error' => 'Carrier API timeout.',
],
],
];
This gives the model enough context to decide whether to ask the customer, escalate, or continue with limited information.
Output truncation
Do not return massive tool payloads. A tool that returns 20,000 tokens of order history will destroy latency, cost, and model attention.
Return summaries:
'recent_orders' => $orders->map(fn ($order) => [
'id' => $order->public_id,
'status' => $order->status->value,
'created_at' => $order->created_at->toIso8601String(),
'total_cents' => $order->total_cents,
])->take(5)->values()->all(),
If the agent needs more detail, give it another tool to fetch a specific record.
🚨 Production warning: the most expensive failure is not malformed JSON. It is a valid-looking tool result that contains too much context. Keep tool payloads small, explicit, and scoped.
7. Production guardrails: authorization, idempotency, and audit trails
Read-only tools are relatively safe. The moment your agent can create tickets, issue refunds, update records, or send emails, you need stronger controls.
A dangerous mistake is registering a tool like this:
public function handle(array $arguments): array
{
$order = Order::findOrFail($arguments['order_id']);
$order->refund($arguments['amount_cents']);
return ['ok' => true];
}
That is not a tool. That is a remote-controlled footgun.
A production tool should look more like an application service:
<?php
namespace App\Support\Agents\Tools;
use App\Services\Refunds\RefundService;
use App\Models\RefundAttempt;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
final class RefundOrderTool implements AgentTool
{
public function __construct(
private RefundService $refunds,
) {}
public function name(): string
{
return 'refund_order';
}
public function description(): string
{
return 'Refund an order if it is eligible. Use only after confirming the order status and customer request.';
}
public function parameters(): array
{
return [
'type' => 'object',
'properties' => [
'order_id' => ['type' => 'string'],
'reason_code' => [
'type' => 'string',
'enum' => [
'damaged_item',
'missing_item',
'late_delivery',
'customer_request',
],
],
],
'required' => ['order_id', 'reason_code'],
'additionalProperties' => false,
];
}
public function rules(): array
{
return [
'order_id' => ['required', 'string', 'regex:/^[A-Z0-9-]{6,32}$/'],
'reason_code' => [
'required',
'string',
'in:damaged_item,missing_item,late_delivery,customer_request',
],
];
}
public function handle(array $arguments): array
{
$order = Order::query()
->where('public_id', $arguments['order_id'])
->first();
if (! $order) {
return ['ok' => false, 'error' => 'Order not found.'];
}
if (Gate::denies('refund', $order)) {
return ['ok' => false, 'error' => 'This order cannot be refunded.'];
}
return DB::transaction(function () use ($order, $arguments) {
$idempotencyKey = hash('sha256', implode('|', [
$order->id,
'refund_order',
$arguments['reason_code'],
]));
$attempt = RefundAttempt::firstOrCreate(
[
'order_id' => $order->id,
'idempotency_key' => $idempotencyKey,
],
[
'completed' => false,
'reason_code' => $arguments['reason_code'],
],
);
if ($attempt->completed) {
return [
'ok' => true,
'refund_id' => $attempt->refund_id,
'duplicate' => true,
];
}
$refund = $this->refunds->refundOrder(
order: $order,
reasonCode: $arguments['reason_code'],
);
$attempt->update([
'completed' => true,
'refund_id' => $refund->id,
]);
return [
'ok' => true,
'refund_id' => $refund->id,
];
});
}
}
There are several deliberate choices here.
First, the tool does not allow the model to choose an arbitrary amount. If partial refunds are needed, that should be a separate, tightly controlled flow, often with human approval.
Second, the tool checks authorization through a policy. The model does not know your business rules, and it should not have to.
Third, the tool uses an idempotency key. If the agent loop retries, or the model repeats the tool call, the application avoids duplicate refunds.
Fourth, the tool returns a structured result that the model can understand:
{
"ok": true,
"refund_id": 842,
"duplicate": true
}
For truly dangerous operations, do not let the agent execute directly. Let it propose the action:
{
"status": "escalated",
"summary": "Customer requested refund for damaged item.",
"suggested_reply": "",
"actions": [
{
"type": "escalate_to_human",
"reason": "Refund requires human approval."
}
]
}
Then a human approves it through your normal admin UI.
Audit everything useful
Log agent runs, but be careful with sensitive content.
Useful audit fields:
[
'agent_run_id' => $run->id,
'step' => $step,
'tool' => $name,
'validation_passed' => $validationPassed,
'duration_ms' => $durationMs,
'model' => config('agents.support.model'),
'tokens_prompt' => $usage['prompt_tokens'] ?? null,
'tokens_completion' => $usage['completion_tokens'] ?? null,
'user_id' => $user->id ?? null,
'conversation_id' => $conversation->id,
]
Be cautious about logging full prompts and tool arguments. They may contain personal data, account numbers, or internal identifiers. If you need full traces for debugging, store them encrypted, redacted, or behind strict access controls.
Tool allowlists
Not every agent should have every tool.
A support triage agent might get:
$registry->add(app(GetOrderTool::class));
$registry->add(app(SearchHelpCenterTool::class));
$registry->add(app(CreateSupportTicketTool::class));
A billing agent might get:
$registry->add(app(GetInvoiceTool::class));
$registry->add(app(GetPaymentMethodTool::class));
$registry->add(app(RequestInvoiceCopyTool::class));
Do not register refund_order for an agent that only answers documentation questions. The safest tool is the one the model cannot call.
8. Test the agent without paying for every edge case
If your tests require a live model, they will be slow, expensive, nondeterministic, and annoying. Test the agent architecture with fakes, then run a small number of live evaluation tests separately.
Laravel’s HTTP fake makes this pleasant.
public function test_agent_returns_decision_after_tool_call(): void
{
Http::fake([
'api.openai.com/v1/chat/completions' => Http::sequence()
->push([
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => null,
'tool_calls' => [
[
'id' => 'call_1',
'type' => 'function',
'function' => [
'name' => 'get_order',
'arguments' => json_encode([
'order_id' => 'ORD-123456',
]),
],
],
],
],
],
],
])
->push([
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => null,
'tool_calls' => [
[
'id' => 'call_2',
'type' => 'function',
'function' => [
'name' => 'submit_decision',
'arguments' => json_encode([
'status' => 'needs_customer_info',
'confidence' => 0.87,
'summary' => 'Order is delayed; address confirmation required.',
'suggested_reply' => 'Please confirm your shipping address.',
'actions' => [
[
'type' => 'request_more_info',
'reason' => 'Carrier needs address confirmation.',
],
],
]),
],
],
],
],
],
],
]),
]);
$result = app(SupportAgent::class)->run(
'My order has not shipped yet.',
['order_id' => 'ORD-123456'],
);
$this->assertTrue($result->successful);
$this->assertSame(AgentStatus::NeedsInfo, $result->decision->status);
$this->assertSame(0.87, $result->decision->confidence);
}
You should also test failure paths.
Test invalid final decisions
public function test_agent_fails_when_final_decision_is_invalid(): void
{
Http::fake([
'api.openai.com/v1/chat/completions' => Http::fake([
'api.openai.com/v1/chat/completions' => Http::response([
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => null,
'tool_calls' => [
[
'id' => 'call_1',
'type' => 'function',
'function' => [
'name' => 'submit_decision',
'arguments' => json_encode([
'status' => 'totally_made_up_status',
'confidence' => 17,
'summary' => 'Bad decision.',
'suggested_reply' => '',
'actions' => [],
]),
],
],
],
],
],
],
]),
]),
]);
$result = app(SupportAgent::class)->run('Where is my order?');
$this->assertFalse($result->successful);
$this->assertStringContainsString('invalid final decision', $result->error);
}
Test invalid tool arguments
public function test_agent_returns_tool_error_for_invalid_order_id(): void
{
Http::fake([
'api.openai.com/v1/chat/completions' => Http::response([
'choices' => [
[
'message' => [
'role' => 'assistant',
'content' => null,
'tool_calls' => [
[
'id' => 'call_1',
'type' => 'function',
'function' => [
'name' => 'get_order',
'arguments' => json_encode([
'order_id' => '<script>alert(1)</script>',
]),
],
],
],
],
],
],
]),
]);
// Assert that the next message sent back to the model contains a tool error.
}
Test tools directly
Tools are ordinary Laravel classes. Test them without the model:
public function test_get_order_tool_returns_minimal_order_payload(): void
{
$order = Order::factory()
->withItems()
->create(['public_id' => 'ORD-123456']);
$tool = app(GetOrderTool::class);
$result = $tool->handle(['order_id' => 'ORD-123456']);
$this->assertTrue($result['ok']);
$this->assertSame('ORD-123456', $result['order']['id']);
$this->assertArrayNotHasKey('customer_email', $result['order']);
}
This gives you a healthy testing pyramid:
- Unit tests for tools, validators, DTOs, and policies.
- Fake HTTP tests for the agent loop.
- Evaluation tests with real model calls, run less frequently.
- Canary tests in staging or production-like environments.
The evaluation tests are where you check quality: Does the agent choose the right status? Does it avoid calling refund tools when it should not? Does it ask for more information instead of hallucinating?
But your core regression suite should not depend on a model being in a good mood.
Choosing the right agent pattern
Not every LLM feature should be an agent. The more autonomy you give the model, the more validation, observability, and cost control you need.
| Pattern | Best for | Reliability | Cost | Risk |
|---|---|---|---|---|
| One-shot structured output | Classification, extraction, summarization | High | Low | Low |
| Read-only tool agent | Support triage, internal lookup, research with guardrails | Medium-high | Medium | Medium |
| Write-capable agent | Refunds, ticket updates, provisioning | Medium | Medium-high | High |
| Fully autonomous multi-agent system | Complex planning, long-running tasks | Lower | High | Very high |
A practical decision framework:
Use one-shot structured output when:
- The task has a fixed output shape.
- No external actions are required.
- The result can be validated easily.
- Latency matters.
- You want predictable cost.
Examples:
Classify this support ticket.
Extract shipping address from this message.
Summarize this conversation into structured notes.
Use a read-only tool agent when:
- The model needs facts from your application.
- Tools do not mutate important state.
- The final decision can be validated.
- You can cap steps and tool calls.
Examples:
Look up the order and suggest a response.
Search documentation and propose troubleshooting steps.
Check subscription status and classify the request.
Use a write-capable agent only when:
- Every mutating tool has authorization.
- Every mutating tool is idempotent.
- Every important action is logged.
- Dangerous actions require approval or have hard limits.
- You can replay and audit agent runs.
Examples:
Create a draft support ticket.
Request a callback.
Initiate a low-risk refund under a strict policy.
If you cannot answer these questions, the agent is not ready:
- What is the exact final output schema?
- Which fields are required?
- Which values are allowed?
- Which tools may the model call?
- Which tools mutate state?
- What happens when validation fails?
- What happens when the provider times out?
- What is the maximum cost per agent run?
- What does the audit trail contain?
- Who approves dangerous actions?
My default Laravel position is conservative: start with a single-step structured task, then add read-only tools, and only then consider write tools. If a write tool feels even slightly scary, convert it into a proposal that a human approves.
The model is not your application’s authority. It is a generator of plausible next steps. Laravel’s job is to make those steps explicit, validated, authorized, and reversible.
That is the difference between an AI demo and an agent you can actually ship.