The agent had already done the difficult work.
It found the customer’s order, checked the payment status, read the refund policy, and calculated the amount.
Then it proposed this:
Action: Issue refund
Order: ORD-1842
Amount: $129.00
Reason: Customer received a damaged product
The calculation looked reasonable. The customer qualified for a refund.
I still did not want the model pressing the final button.
A refund changes real business data and moves money. The agent could recommend the action, but a person needed to approve it.
The Dangerous Line Was One Method Call
My first version exposed an IssueRefund tool directly to the agent.
The instructions included a sentence like:
Always ask a manager before issuing refunds above $25.
That sounded safe until I considered what enforced it.
The same model deciding to issue the refund was also responsible for remembering when it needed permission. A changed prompt, incomplete conversation, or different model response could bypass that instruction.
The permission boundary belonged in PHP.
Laravel released AI SDK v1.0 on September 23, 2026. Among its production features is human approval for agent tools. An approvable tool pauses before its handle() method executes.
That was the behavior I needed.
Making the Refund Tool Approvable
Here is a simplified version of the tool:
<?php
namespace App\Ai\Tools;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Approvals\Approval;
use Laravel\Ai\Concerns\InteractsWithApprovals;
use Laravel\Ai\Contracts\Approvable;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class IssueRefund implements Approvable, Tool
{
use InteractsWithApprovals;
public function description(): Stringable|string
{
return 'Issue an approved refund for an existing order.';
}
protected function needsApproval(Request $request): Approval|bool
{
$amount = (int) $request['amount_cents'];
return $amount <= 2500
? false
: Approval::required(
'Refunds above $25 require manager approval.'
);
}
public function handle(Request $request): Stringable|string
{
return app(RefundService::class)->issueOnce(
orderId: $request['order_id'],
amountInCents: $request['amount_cents'],
reason: $request['reason'],
);
}
public function schema(JsonSchema $schema): array
{
return [
'order_id' => $schema->string()->required(),
'amount_cents' => $schema->integer()->min(1)->required(),
'reason' => $schema->string()->required(),
];
}
}
Refunds of $25 or less can continue automatically. Anything above that amount produces an approval request.
The threshold is ordinary application logic. The model cannot negotiate with it or reinterpret it.
A Pause Is a Real Workflow State
When the agent selects IssueRefund, Laravel returns the pending tool call instead of executing it.
$response = (new RefundAgent)
->forUser($user)
->prompt('Refund the damaged order ORD-1842.');
if ($response->hasPendingApprovals()) {
foreach ($response->pendingApprovals as $approval) {
// Display the tool name, arguments and reason.
}
}
The approval screen should show the exact order, amount, reason, and requested action. A vague “Allow this agent to continue?” message does not give the reviewer enough information.
After the manager approves or rejects the request, the application resumes the conversation:
use Laravel\Ai\Approvals\Decision;
use Laravel\Ai\Approvals\Decisions;
$response = (new RefundAgent)
->continue($conversationId, as: $manager)
->prompt(Decisions::from([
$toolCallId => Decision::approve(),
]));
Laravel also supports rejecting the request or editing its arguments before execution. The human tool approval documentation covers the full flow.
Approval Is Not Authorization
This was the most important lesson from the build.
An approval button does not prove that the current user is allowed to approve the refund.
Before resuming the agent, the application must verify that:
- The conversation belongs to the correct account
- The signed-in user has refund approval permission
- The order still qualifies for a refund
- The approved amount remains valid
- The action has not already been completed
The refund service also needs an idempotency rule. If a queue retries or a user submits the approval twice, the customer should still receive only one refund.
The official documentation warns that paused turns are matched through the conversation and pending tool calls. The application is responsible for authorizing access before resuming them.
The Tests That Matter
A happy-path test proves that an approved refund can succeed. That is only the beginning.
I would also test these cases:
- An unauthorized employee attempts to approve the refund
- The order status changes while approval is pending
- A manager edits the amount before approval
- The same approval is submitted twice
- The approval references an unknown tool-call ID
- A rejected action returns a useful explanation
- An old approval expires before execution
These tests check the business boundary around the model rather than the quality of its wording.
If you are comparing teams under a search such as best Laravel development company, ask how they would authorize, resume, audit, and retry this workflow. That answer reveals more than another framework quiz.
The agent can collect evidence and recommend a refund.
PHP decides whether the tool must pause. Laravel records the pending action. An authorized person makes the final decision. The service executes it once.
That separation is what made the agent safe enough to use.
Which action in your application should an AI agent never execute without approval?
Top comments (0)