You want an AI agent to do something useful: mark an order as shipped, add a note to a ticket, update a CRM field, assign a support case, apply a small discount after reviewing a complaint.
The fast version is tempting:
- give the agent an admin API token,
- expose a generic
PATCH /orders/{id}endpoint, - let Eloquent mass-assign whatever comes in,
- hope the model’s prompt is good enough.
That version works in a demo. It tends to fall apart the first time the agent misinterprets a support email, retries a failed request eleven times, gets influenced by malicious text inside a ticket, or tries to update a record it should never have seen.
The real requirement is not “can the agent write to the database?”
The real requirement is: can the agent perform a narrow set of business mutations, on a narrow set of records, under conditions you explicitly allow, with an audit trail you can trust?
This article walks through practical Laravel patterns for letting an AI agent update database records without handing it the keys to the whole application.
TL;DR
If you only take away one thing, take this:
Do not give an AI agent direct database access or a generic update endpoint. Give it a machine identity, scoped token abilities, narrow mutation routes, record-level policies, strict validation, explicit domain methods, audit logs, and — for dangerous operations — an approval workflow.
In most Laravel apps, the safest architecture looks like this:
- The agent authenticates with its own Sanctum token.
- The token only has narrow abilities such as
orders:update-status. - Each mutation has its own route and controller action.
- A policy checks whether the agent can update that specific record.
- A form request validates the exact payload.
- A domain method performs the state transition.
- An audit log records who changed what and why.
- Dangerous changes become proposals that a human approves.
📋 Table of Contents
- The Real Problem: An AI Agent Is a Nondeterministic Write Client
- 1. Treat the Agent Like an Untrusted API Client, Not a Teammate
- 2. Give the Agent a Machine Identity, Not an Admin Token
- 3. Expose Mutations as Narrow Endpoints, Not Generic Update Routes
- 4. Use Policies as the Record-Level Firewall
- 5. Validate the Business Transition, Not Just the JSON Shape
- 6. Replace
fill()With Explicit Domain Methods - 7. Scope the Query Before the Agent Ever Sees the Record
- 8. For Dangerous Writes, Make the Agent Propose Instead of Apply
- 9. Design for Agent Failure: Idempotency, Throttling, and Dry Runs
- 10. Let the Database Be the Last Guardrail, Not the First Policy
- Choosing the Right Mix
The Real Problem: An AI Agent Is a Nondeterministic Write Client
A normal API client is usually deterministic. A mobile app sends a request when a user taps a button. An admin panel sends a request when an employee submits a form. If something goes wrong, there is usually a human in the loop who sees the result immediately.
An AI agent is different.
It may:
- decide to call a tool based on ambiguous natural-language input,
- be influenced by content it reads from tickets, emails, PDFs, or web pages,
- retry requests automatically,
- call tools in an unexpected order,
- generate syntactically valid JSON that is business-invalid,
- attempt to update records that are technically visible but operationally off-limits,
- keep trying after a partial failure.
That changes the security model.
With a normal user, you can often assume that the user interface prevents most invalid actions. With an agent, the API itself must assume the caller may behave strangely.
So the question becomes:
How do we allow the agent to make useful writes while making unsafe writes impossible, improbable, or at least reviewable?
Laravel already has most of the pieces you need: authentication, authorization, validation, Eloquent, policies, middleware, events, queues, and transactions. The trick is to use them deliberately.
1. Treat the Agent Like an Untrusted API Client, Not a Teammate
Scenario:
Your team builds an internal support agent. It reads tickets, summarizes customer issues, and can update ticket status. The first version uses a senior developer’s admin token because “it’s just internal for now.”
Why it matters:
Once the agent has an admin token, the blast radius is enormous. If the agent is misdirected, compromised, or prompted maliciously, it can do anything that admin can do.
Solution:
Treat the agent like an external integration. It should get the same kind of scrutiny you would give to a third-party webhook consumer or partner API client.
That means:
- no shared human credentials,
- no admin token,
- no direct database credentials,
- no generic write access,
- no reliance on the prompt as a security boundary.
The prompt is not authorization. The prompt is intent. Authorization belongs in deterministic Laravel code.
A useful mental model:
| Layer | Example | Trust level |
|---|---|---|
| Prompt / tool description | “Only update orders that belong to the customer” | Not trusted |
| API token abilities | orders:update-status | Trusted as coarse permission |
| Policy / scopes | “Only orders from this store and not locked” | Trusted as record-level authorization |
| Validation / domain rules | “Pending can become paid, but shipped cannot become pending” | Trusted as business logic |
| Audit / proposals | “Record every change or require approval” | Trusted as operational control |
The prompt can guide the agent, but it cannot replace authorization.
🚨 Production warning: If your security argument includes “we told the agent not to do that,” you do not have a security control. You have a hope.
2. Give the Agent a Machine Identity, Not an Admin Token
The first practical step is to give the agent its own identity.
Do not let the agent reuse a human user account. Do not let it borrow a developer token. Do not let it authenticate as the system root user.
In a Laravel application using Sanctum, a simple approach is to create a dedicated service account user with a role such as agent, then issue a token with limited abilities.
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
$agent = User::forceCreate([
'name' => 'Support Copilot Agent',
'email' => 'support-copilot@internal.example',
'password' => Hash::make(Str::random(40)),
'role' => 'agent',
'home_store_id' => 12,
]);
$token = $agent->createToken('support-copilot', [
'tickets:add-note',
'orders:update-status',
])->plainTextToken;
Then your routes can require both authentication and the exact ability.
use Illuminate\Support\Facades\Route;
Route::prefix('agent')
->middleware(['auth:sanctum'])
->group(function () {
Route::post('/orders/{order}/status', [AgentOrderStatusController::class, 'update'])
->middleware(['ability:orders:update-status', 'throttle:agent']);
Route::post('/tickets/{ticket}/notes', [AgentTicketNoteController::class, 'store'])
->middleware(['ability:tickets:add-note', 'throttle:agent']);
});
This gives you two useful boundaries.
First, the agent cannot call an endpoint unless it has a token. Second, even if the token leaks, it should not have abilities beyond the agent’s intended job.
Token abilities are coarse, not final
A common mistake is to stop here.
Token abilities are good for broad permissioning:
orders:update-statusorders:add-notetickets:assigncrm:update-company-size
But they usually do not answer the harder questions:
- Which order?
- Which ticket?
- Is the record locked?
- Is the agent assigned to this tenant?
- Is the record in a state that allows this mutation?
- Is the change too dangerous to apply automatically?
Those questions belong in policies, scopes, and domain logic.
💡 Practical note: If you have separate teams, tenants, or environments, create separate agent identities per scope. One agent token for production support, another for staging, another for billing operations. Revoking one should not break everything.
3. Expose Mutations as Narrow Endpoints, Not Generic Update Routes
Once the agent has an identity, the next decision is what HTTP surface it can use.
A dangerous pattern is this:
Route::patch('/agent/orders/{order}', [AgentOrderController::class, 'update']);
Then the controller does something like:
$order->update($request->validated());
That route says: “Here is an order. Tell me what to change.”
For an AI agent, that is too broad.
A better pattern is to expose named mutations:
Route::post('/agent/orders/{order}/status', [AgentOrderStatusController::class, 'update']);
Route::post('/agent/orders/{order}/notes', [AgentOrderNoteController::class, 'store']);
Route::post('/agent/orders/{order}/shipping-address', [AgentOrderAddressController::class, 'update']);
Each endpoint represents one business action.
For example:
use App\Http\Requests\AgentUpdateOrderStatusRequest;
use App\Models\Order;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AgentOrderStatusController extends Controller
{
public function update(AgentUpdateOrderStatusRequest $request, int $orderId): JsonResponse
{
$order = Order::query()
->visibleToAgent($request->user())
->findOrFail($orderId);
$this->authorize('updateStatus', $order);
$order->changeStatus(
newStatus: OrderStatus::from($request->validated('status')),
actor: $request->user(),
reason: $request->validated('reason'),
);
return response()->json([
'id' => $order->id,
'status' => $order->status,
'updated_at' => $order->updated_at,
]);
}
}
This does several important things:
- the endpoint name describes the mutation,
- the request payload is limited,
- the policy can be specific to the action,
- the domain method can enforce a state transition,
- the response can return only what the agent needs.
Why narrow endpoints matter for AI agents
An agent calling tools benefits from narrow endpoints because they reduce ambiguity.
A tool named update_order_status with a strict schema is easier to reason about than a tool named update_order that accepts arbitrary fields.
It also makes authorization easier. You are not asking:
Can this agent update orders?
You are asking:
Can this agent update this order’s status, right now, to this value?
That is a much safer question.
⚠️ Gotcha: If your agent framework supports tool definitions, generate the tool schema from your Laravel form request rules where possible. If the tool schema and server-side validation drift, the agent will waste retries on invalid requests.
4. Use Policies as the Record-Level Firewall
Token abilities tell Laravel what kind of action the agent may attempt. Policies tell Laravel whether the agent may attempt that action on a specific record.
This distinction matters.
An agent may have orders:update-status, but that does not mean it can update every order.
Maybe it can only update orders from its assigned store. Maybe it cannot update locked orders. Maybe it cannot update orders older than 30 days. Maybe it cannot update orders already fulfilled by a third-party logistics provider.
A policy is the right place for these rules.
namespace App\Policies;
use App\Models\Order;
use App\Models\User;
class OrderPolicy
{
public function updateStatus(User $actor, Order $order): bool
{
if ($actor->isAdmin()) {
return true;
}
if (! $actor->isAgent()) {
return false;
}
return $order->store_id === $actor->home_store_id
&& ! $order->isLocked()
&& ! $order->isFulfilledByThirdParty();
}
}
Then the controller authorizes the action:
$this->authorize('updateStatus', $order);
The important part is that the policy receives the actual model instance. That means your authorization decision can depend on the state of the record.
This is much stronger than checking only the route or token.
Separate coarse ability from fine-grained policy
A clean division is:
- Sanctum ability: “Can this token use this category of endpoint?”
- Policy: “Can this actor perform this action on this record?”
For example:
Route::post('/agent/orders/{order}/status', [AgentOrderStatusController::class, 'update'])
->middleware(['auth:sanctum', 'ability:orders:update-status']);
Then inside the controller:
$this->authorize('updateStatus', $order);
If you want defense in depth, you can also check the ability inside the form request or policy. But avoid making the system so redundant that nobody understands which layer is responsible for what.
A good rule:
| Authorization question | Best place |
|---|---|
| Is the caller authenticated? | Auth middleware |
| Does the token have this broad capability? | Sanctum ability middleware |
| Can this actor perform this action on this record? | Policy |
| Is the payload valid? | Form request |
| Is the business transition valid? | Domain method / state machine |
🔍 Why this matters: Policies force you to authorize the actual record. That is the difference between “the agent can update orders” and “the agent can update this order.”
5. Validate the Business Transition, Not Just the JSON Shape
AI agents are very good at producing valid JSON.
That is part of the problem.
A request can be structurally valid and still completely wrong:
{
"status": "pending",
"reason": "Customer asked to revert order"
}
If the order is already shipped, that request is syntactically fine but operationally dangerous.
Validation needs to happen at two levels:
- Payload validation.
- Business-state validation.
Payload validation belongs in a form request.
namespace App\Http\Requests;
use App\Enums\OrderStatus;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AgentUpdateOrderStatusRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->tokenCan('orders:update-status') ?? false;
}
public function rules(): array
{
return [
'status' => [
'required',
Rule::in([
OrderStatus::Paid->value,
OrderStatus::Shipped->value,
]),
],
'reason' => [
'required',
'string',
'min:10',
'max:500',
],
];
}
protected function prepareForValidation(): void
{
$status = $this->input('status');
if (is_string($status)) {
$this->merge([
'status' => strtolower(trim($status)),
]);
}
}
}
This handles the obvious concerns:
- the status is present,
- the status is one of the values the agent is allowed to send,
- the reason is not empty,
- the reason is not enormous.
But this is not enough.
The request may still try to move an order from shipped back to paid, or from cancelled to shipped. Those are state-transition problems, not payload-shape problems.
That is where domain logic comes in.
A useful enum can encode allowed transitions:
namespace App\Enums;
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function canTransitionTo(self $next): bool
{
return match ($this) {
self::Pending => in_array($next, [self::Paid, self::Cancelled], true),
self::Paid => in_array($next, [self::Shipped, self::Cancelled], true),
self::Shipped, self::Cancelled => false,
};
}
}
Now the application can reject impossible transitions even if the payload is valid.
This matters even more with AI agents because agents often infer actions from messy context. They may see a customer say, “I want my order back to original state,” and infer a status change that your business does not allow.
🧠 The important part: Validation is not just “is this field a string?” For agent-driven writes, validation must include “is this mutation allowed on this record in its current state?”
6. Replace fill() With Explicit Domain Methods
Mass assignment is convenient for human forms. It can be dangerous for agent-driven mutations.
If your controller does this:
$order->update($request->validated());
then the safety of your system depends heavily on $fillable, validation, and hope.
For agent writes, prefer explicit domain methods.
Instead of:
$order->update([
'status' => $request->validated('status'),
'status_changed_reason' => $request->validated('reason'),
]);
do this:
$order->changeStatus(
newStatus: OrderStatus::from($request->validated('status')),
actor: $request->user(),
reason: $request->validated('reason'),
);
The model method becomes the only supported path for that mutation.
namespace App\Models;
use App\Enums\OrderStatus;
use App\Exceptions\InvalidOrderStatusTransition;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class Order extends Model
{
protected function casts(): array
{
return [
'status' => OrderStatus::class,
'status_changed_at' => 'datetime',
];
}
public function changeStatus(OrderStatus $newStatus, User $actor, string $reason): void
{
if (! $this->status->canTransitionTo($newStatus)) {
throw InvalidOrderStatusTransition::from($this, $newStatus);
}
$previousStatus = $this->status;
DB::transaction(function () use ($newStatus, $actor, $reason, $previousStatus) {
$this->forceFill([
'status' => $newStatus,
'status_changed_by' => $actor->id,
'status_changed_reason' => $reason,
'status_changed_at' => now(),
])->save();
$this->statusChanges()->create([
'from' => $previousStatus,
'to' => $newStatus,
'actor_id' => $actor->id,
'reason' => $reason,
]);
});
}
}
This gives you several advantages.
First, the mutation has a name. It is not a generic update.
Second, the method can enforce invariants before saving.
Third, you can create audit records in the same transaction.
Fourth, you can later change the implementation without changing the controller or agent tool contract.
Fifth, you avoid accidental field changes. If the agent somehow sends extra data, the domain method simply does not care.
Be careful with forceFill()
Using forceFill() inside a controlled domain method is very different from using it in a controller.
Inside a domain method, you control the exact attributes being written:
$this->forceFill([
'status' => $newStatus,
'status_changed_by' => $actor->id,
])->save();
In a controller, forceFill($request->all()) is a disaster waiting to happen.
The same applies to fillable. A field may be fillable for an admin form but should not be writable by an agent.
For example, discount_cents may be fillable for finance admins but not for a support agent. The agent should not be able to write it just because Eloquent technically allows it.
⚠️ Gotcha: Do not rely on
$fillableas your only agent permission model. Fillable attributes are usually broader than the set of attributes any one actor should be able to change.
7. Scope the Query Before the Agent Ever Sees the Record
Another common mistake is authorizing after loading an unscoped record.
For example:
$order = Order::findOrFail($orderId);
$this->authorize('updateStatus', $order);
This can work, but it means the agent can probe for records by ID. It also means your policy has to be perfect for every record in the table.
A stronger pattern is to scope the query first:
$order = Order::query()
->visibleToAgent($request->user())
->findOrFail($orderId);
Then define the scope:
namespace App\Models;
use App\Enums\OrderStatus;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class Order extends Model
{
public function scopeVisibleToAgent(Builder $query, User $agent): Builder
{
return $query
->where('store_id', $agent->home_store_id)
->where('status', '!=', OrderStatus::Cancelled)
->whereDoesntHave('locks', function (Builder $query) {
$query->where('expires_at', '>', now());
});
}
}
Now the agent cannot even find records outside its allowed set.
This is especially important in multi-tenant apps.
If your agent works for one tenant, it should not be able to access another tenant’s records by guessing IDs. The tenant boundary should be part of the query, not only part of a later policy check.
Combine scopes with locking
If the agent updates records that humans also update, you need to think about race conditions.
For example:
- agent marks an order as shipped,
- support admin cancels the order at the same time,
- both writes succeed,
- the order ends up in a broken state.
For critical mutations, use a transaction and row locking:
DB::transaction(function () use ($orderId, $request) {
$order = Order::query()
->visibleToAgent($request->user())
->lockForUpdate()
->findOrFail($orderId);
$this->authorize('updateStatus', $order);
$order->changeStatus(
newStatus: OrderStatus::from($request->validated('status')),
actor: $request->user(),
reason: $request->validated('reason'),
);
});
This is not always necessary. For low-risk changes, it may be overkill. But for inventory, payments, shipping, or assignment workflows, it can prevent subtle and annoying production bugs.
💡 Practical note: If the agent receives record IDs from untrusted text, assume those IDs may be wrong. Scoped queries reduce the damage when the agent confidently points at a record it should not touch.
8. For Dangerous Writes, Make the Agent Propose Instead of Apply
Some mutations are too sensitive for direct agent writes.
Examples:
- applying a large discount,
- changing a customer’s billing email,
- modifying a subscription plan,
- deleting a resource,
- changing permissions,
- updating payout details,
- approving a refund,
- editing a legal document record.
For those, do not let the agent perform the final write. Let it create a proposal.
The basic idea:
- Agent reads context.
- Agent creates a
PendingOrderChangerecord. - A human reviews it.
- A human approves it through a separate authorized endpoint.
- The system applies the change using normal domain rules.
A simple model might look like this:
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class PendingOrderChange extends Model
{
protected function casts(): array
{
return [
'payload' => 'array',
'expires_at' => 'datetime',
'resolved_at' => 'datetime',
];
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
}
The agent endpoint creates the proposal:
use App\Models\Order;
use App\Models\PendingOrderChange;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AgentOrderDiscountProposalController extends Controller
{
public function store(Request $request, int $orderId): JsonResponse
{
$order = Order::query()
->visibleToAgent($request->user())
->findOrFail($orderId);
$this->authorize('proposeDiscount', $order);
$validated = $request->validate([
'discount_cents' => ['required', 'integer', 'min:1', 'max:5000'],
'reason' => ['required', 'string', 'min:10', 'max:500'],
]);
$proposal = PendingOrderChange::create([
'order_id' => $order->id,
'action' => 'apply_discount',
'payload' => [
'discount_cents' => $validated['discount_cents'],
'reason' => $validated['reason'],
],
'requested_by' => $request->user()->id,
'status' => 'pending',
'expires_at' => now()->addHour(),
]);
return response()->json([
'proposal_id' => $proposal->id,
'status' => $proposal->status,
], 202);
}
}
Then a human approves it:
class PendingOrderChangeApprovalController extends Controller
{
public function approve(Request $request, PendingOrderChange $proposal): JsonResponse
{
$this->authorize('approve', $proposal);
if ($proposal->status !== 'pending' || $proposal->isExpired()) {
return response()->json([
'message' => 'Proposal is no longer active.',
], 422);
}
DB::transaction(function () use ($proposal, $request) {
$order = Order::query()
->lockForUpdate()
->findOrFail($proposal->order_id);
$order->applyAgentDiscount(
discountCents: $proposal->payload['discount_cents'],
approver: $request->user(),
reason: $proposal->payload['reason'],
);
$proposal->forceFill([
'status' => 'approved',
'resolved_by' => $request->user()->id,
'resolved_at' => now(),
])->save();
});
return response()->json($proposal->fresh());
}
}
This pattern is powerful because it separates intelligence from authority.
The agent can still do the useful cognitive work: read the ticket, understand the complaint, calculate a reasonable discount, summarize the justification. But it does not get final authority over risky mutations.
Use expiration and single-use proposals
A proposal should not live forever.
Give it:
- an expiration time,
- a status,
- a requester,
- a resolver,
- a resolution timestamp,
- maybe a rejection reason.
Also make sure a proposal cannot be approved twice.
If the approval endpoint is called again, it should fail unless the proposal is still pending.
This is not just security. It is operational sanity.
When something goes wrong, you want to know:
- who requested the change,
- what proposed payload was used,
- who approved it,
- when it happened,
- whether it expired,
- whether it was rejected,
- whether it was applied.
🚨 Production warning: Approval workflows are not only for security. They are also a user-interface decision. If humans are going to approve agent proposals, give them enough context: the original reason, the affected record, the risk level, and a clear approve/reject flow.
9. Design for Agent Failure: Idempotency, Throttling, and Dry Runs
AI agents fail in ways that normal users rarely do.
They may:
- retry the same tool call repeatedly,
- call a tool with the same business intent but different IDs,
- timeout while the server still processes the request,
- receive an ambiguous error and try again,
- run multiple parallel tasks that touch the same record.
If your write endpoints are not designed for that, you will get duplicate notes, duplicate status changes, duplicate emails, and weird inconsistent states.
Idempotency keys
For any mutation that matters, require an idempotency key.
The agent sends a header:
Idempotency-Key: 8f2b0f3a-0c4e-4f6a-9a2d-6d5f2b2d8b1a
Your application stores the key with the mutation result. If the same key is received again, return the original response instead of performing the mutation again.
A simplified version:
use App\Models\AgentMutationLog;
use Illuminate\Http\Request;
$existing = AgentMutationLog::query()
->where('agent_id', $request->user()->id)
->where('idempotency_key', $request->header('Idempotency-Key'))
->first();
if ($existing) {
return response()->json($existing->response_payload, $existing->status_code);
}
Then after the mutation succeeds:
AgentMutationLog::create([
'agent_id' => $request->user()->id,
'idempotency_key' => $request->header('Idempotency-Key'),
'endpoint' => $request->path(),
'request_payload' => $request->validated(),
'response_payload' => $responseData,
'status_code' => 200,
]);
In a real system, you would want more care around unique constraints, race conditions, and partial failures. But the principle is essential: agent mutations should be safely retryable.
If your agent framework cannot send custom headers, create a business-level idempotency key from stable data:
- agent ID,
- record ID,
- action name,
- intended transition,
- time bucket.
But be careful: deriving keys automatically can accidentally suppress legitimate repeated actions. For example, adding the same note twice may be valid if the user asked twice.
Rate limiting
Agents can generate a lot of traffic quickly.
Define a named limiter:
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('agent', function (Request $request) {
return Limit::perMinute(120)->by(
$request->user()?->id ?: $request->ip()
);
});
Then apply it:
Route::post('/agent/orders/{order}/status', [AgentOrderStatusController::class, 'update'])
->middleware(['auth:sanctum', 'ability:orders:update-status', 'throttle:agent']);
You can also create more specific limits:
agent:order-statusagent:discount-proposalagent:readagent:high-risk
High-risk endpoints should have lower limits than read or note-adding endpoints.
Dry-run endpoints
A useful pattern for agent systems is the dry-run endpoint.
Instead of:
POST /agent/orders/{order}/status
you also support:
POST /agent/orders/{order}/status/dry-run
It returns:
- whether the payload is valid,
- whether the record exists,
- whether the agent is authorized,
- whether the transition is allowed,
- what the result would be,
- any warnings.
This is especially useful when the agent is operating in a cautious mode or when a human asks it to “check whether this would work” before applying it.
Dry runs also make debugging much easier. When the agent says, “I cannot update the order,” you can look at the dry-run response and see whether the failure was validation, authorization, state transition, or throttling.
💡 Practical note: Return machine-readable error codes. “Invalid transition” is better than a generic 422 message. Agents can route on codes like
invalid_state_transition,record_locked,permission_denied, orrate_limited.
10. Let the Database Be the Last Guardrail, Not the First Policy
Laravel should be your primary authorization and validation layer. But the database can still help.
Do not give the agent direct database credentials. In most cases, that is the wrong architecture. The agent should use your application’s API so that business rules, authorization, validation, auditing, and throttling are applied.
However, the database can still act as a final guardrail.
For example, you can enforce status values:
ALTER TABLE orders
ADD CONSTRAINT orders_status_check
CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled'));
You can also enforce numeric boundaries:
ALTER TABLE orders
ADD CONSTRAINT orders_discount_cents_check
CHECK (discount_cents >= 0 AND discount_cents <= 5000);
In a Laravel migration, you might use raw statements:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement("
ALTER TABLE orders
ADD CONSTRAINT orders_discount_cents_check
CHECK (discount_cents >= 0 AND discount_cents <= 5000)
");
}
public function down(): void
{
DB::statement('ALTER TABLE orders DROP CONSTRAINT orders_discount_cents_check');
}
};
These constraints are not a replacement for application logic. They are a safety net.
They protect you when:
- a bug bypasses validation,
- a developer writes a raw query,
- an import script goes wrong,
- a queued job mutates data unexpectedly,
- an internal tool forgets a guardrail.
Database users and permissions
Even if the agent does not connect directly to the database, your application’s database user should still follow least privilege.
For example:
- no DDL rights in production,
- no superuser privileges,
- no access to unrelated databases,
- separate credentials per environment,
- credentials stored in a secret manager,
- rotations planned.
If you absolutely must let an analytics or read-only agent inspect data, give it a read-only database user or a read replica. Do not let it share the application’s write credentials.
For multi-tenant Postgres systems, row-level security can be an additional layer. But do not start there. Start with clear Laravel authorization first. Database-level row security can become hard to reason about if every query path is not carefully tested.
🔍 Why this matters: Application rules explain intent. Database constraints prevent certain outcomes even when application code is wrong. You want both, but in the right order.
Choosing the Right Mix
You do not need every pattern for every agent. A read-only summarization agent needs far less protection than an agent that can modify payments.
But most production write agents need a combination.
Here is a practical comparison:
| Control | What it helps prevent | Where it lives | If you skip it |
|---|---|---|---|
| Dedicated agent identity | Shared credentials, unclear attribution | User/agent table, Sanctum token | You cannot tell what the agent did |
| Token abilities | Overbroad endpoint access | Sanctum token scopes | Agent can call more endpoints than intended |
| Narrow mutation routes | Generic field updates | Routes/controllers | Agent may update fields you did not intend |
| Policies | Wrong record access | Laravel policies | Agent may update records outside its scope |
| Query scopes | Tenant leakage, ID probing | Eloquent scopes | Agent may load records it should not see |
| Form requests | Invalid payloads | Laravel validation | Bad data reaches domain logic |
| State machine rules | Invalid transitions | Enums/domain methods | Records enter impossible states |
| Domain methods | Mass-assignment mistakes | Eloquent models/services | Fields get written outside intended paths |
| Approval proposals | Dangerous automatic writes | Pending mutation workflow | High-risk changes happen without review |
| Idempotency | Duplicate writes | Mutation logs/unique keys | Retries create duplicate side effects |
| Rate limits | Runaway agent behavior | Laravel throttles | Agent can overload your app |
| Audit trail | Lack of accountability | Events/logs/tables | You cannot reconstruct what happened |
| DB constraints | Data corruption from bugs | Database | Bad values survive application mistakes |
What I would ship first
If I were adding an AI agent to an existing Laravel application, I would not start with a generic agent framework and a database tool. I would start with one narrow business action.
For example: “The agent can add a note to a ticket.”
That action is useful, low-risk, and easy to audit.
I would implement:
- A dedicated agent user.
- A Sanctum token with
tickets:add-note. - A route:
POST /agent/tickets/{ticket}/notes. - A policy:
addNote. - A form request validating the note body.
- A domain method:
$ticket->addAgentNote($body, $actor). - An audit record including agent ID and request ID.
- A rate limit.
Then I would move to slightly riskier actions:
- update ticket status,
- assign ticket to a queue,
- update CRM field,
- update order note,
- update order status.
Only after that would I consider high-risk actions, and even then I would prefer proposals over direct writes.
A good rollout order:
- Read-only tools.
- Append-only tools, such as notes or comments.
- Limited state transitions.
- Scoped field updates.
- Proposal-only mutations.
- Direct high-risk writes only if there is a very strong reason.
A useful final test
Before enabling any agent write path, ask:
If the agent sends the worst plausible valid request it can imagine, what is the worst thing it can do?
If the answer is:
- it can update the wrong tenant’s record,
- it can change any field,
- it can bypass a state machine,
- it can retry indefinitely,
- it can make a dangerous change without review,
- nobody can trace the action back to the agent,
then the feature is not ready.
The goal is not to prevent the agent from being useful. It is to make the agent’s useful actions predictable, bounded, and reviewable.
A well-designed Laravel write path should make the agent feel less like a database admin and more like a carefully permissioned integration: capable, but constrained; helpful, but accountable.