AI agents don’t use web browsers. They don’t click buttons, submit forms, or trigger standard HTTP requests that pass through your middleware stack. They execute logic via API calls, background queues, or CLI commands using tool definitions.
When an LLM decides to "fetch the latest invoices," it usually calls a tool function. If that tool function just runs Invoice::all(), your AI agent just became a god-mode data leak.
The fundamental problem with integrating LLMs into existing applications is that agents operate in a detached, stateless execution context. They don't have a session cookie. They don't inherently know who invoked them. If you rely on the system prompt to tell the LLM, "Only show John his own data," you are trusting a probabilistic text generator to enforce your security boundary. That is a production incident waiting to happen.
To build a secure AI agent in Laravel, you must treat the LLM not as a user, but as a proxy for the user. The agent must inherit the exact Role-Based Access Control (RBAC) and Attribute-Based Access Control (ABAC) constraints of the human sitting behind the keyboard, and it must enforce those constraints at the database query level, not the prompt level.
TL;DR
- AI agents bypass traditional web middleware because they execute logic through background tools and function calling.
- Never trust the LLM to filter its own results. Force filtering through Eloquent scopes and authorization gates.
- Pass the acting user's identity explicitly into the agent's execution context using Laravel's auth guards or custom context DTOs.
- For complex rules (hierarchies, multi-tenancy, ABAC), standard role packages fall short. Tools like
hosseinhezami/laravel-permission-managerare required to evaluate deep permission trees inside agent tools. - Audit every tool execution with the acting user's ID, not the system service account.
📋 Table of Contents
- 1. The "God-Mode Tool" Problem
- 2. Passing Identity Down the Execution Chain
- 3. Enforcing RBAC Inside LLM Tool Definitions
- 4. Scoping Database Queries with Permission-Aware Eloquent
- 5. Handling ABAC and Complex Hierarchies with laravel-permission-manager
- 6. The "Read-Only" Illusion and Tool Mutations
- 7. Auditing Agent Actions Without Breaking Context
- 8. Architecture Decision Guide: Where to Enforce Permissions
1. The "God-Mode Tool" Problem
Scenario:
You build an internal support bot. A support agent named Sarah asks the bot, "Show me my open tickets." The LLM parses the intent and calls the get_open_tickets tool. The tool executes Ticket::where('status', 'open')->get(). Sarah receives a list of 400 tickets, including highly sensitive enterprise accounts she isn't assigned to.
Why it matters:
In a traditional web app, the controller would grab auth()->id() and scope the query. But an LLM tool is often just a standalone PHP class or a background job triggered by an orchestration engine (like Prism, Echo, or a custom agent loop). The tool has no HTTP request lifecycle. It has no session. It executes with the permissions of the system worker—which is usually unrestricted.
Solution:
You must explicitly bind the human user's identity to the agent's execution context before the tool runs. The tool must never assume it has access to the whole database; it must assume it only has access to what the invoking user is allowed to see.
Code:
// app/AI/Tools/GetOpenTickets.php
class GetOpenTickets
{
public function handle(AgentContext $context): array
{
// The tool explicitly scopes to the human who invoked the agent
return Ticket::query()
->where('status', 'open')
->accessibleBy($context->actingUser) // Custom scope
->get()
->toArray();
}
}
Why this works:
By injecting an AgentContext (or using Laravel's Gate which relies on the currently set user), you shift the security boundary from the LLM's reasoning to your application's deterministic authorization layer. The LLM can ask for "all tickets," but the database will only return the tickets Sarah is allowed to see.
🚨 Production warning:
Never pass the entire unfiltered dataset to the LLM and ask it to "filter out the ones Sarah shouldn't see." This wastes tokens, increases latency, and guarantees a data leak the moment the context window gets messy. Filter at the SQL level.
2. Passing Identity Down the Execution Chain
Scenario:
Your agent runs asynchronously. The user sends a chat message, which is queued. A background worker picks up the job, generates the LLM response, and executes tools. By the time the tool runs, the HTTP request is long gone, and auth()->user() is null.
Why it matters:
If the background worker executes as a generic system user, all Gates and Policies will evaluate against the system user, either failing everything or bypassing user-specific restrictions entirely.
Solution:
When dispatching the agent job, capture the acting user. When the job boots up the agent loop, temporarily set that user as the authenticated user for the lifecycle of the job, or pass a dedicated context object to every tool.
Code:
// app/Jobs/RunAgentLoop.php
class RunAgentLoop implements ShouldQueue
{
public function __construct(
public int $actingUserId,
public string $prompt
) {}
public function handle(): void
{
$actingUser = User::find($this->actingUserId);
// Temporarily authenticate the system worker as the acting user
// This allows Laravel Gates and Policies to work normally
Auth::setUser($actingUser);
$agent = Agent::build()
->withPrompt($this->prompt)
->execute();
// Process agent response...
}
}
Why this works:
Laravel’s Gate facade automatically resolves the current user via the Auth manager. By using Auth::setUser($actingUser) inside the queue worker, you seamlessly trick Laravel’s authorization layer into thinking the human user is making the request. All existing Policies will "just work" without rewriting them specifically for AI tools.
💡 Practical note:
If your background workers process multiple jobs concurrently (e.g., using Horizon with multiple threads), ensure you reset the auth state or use a scoped context container to prevent "user bleeding" where Job B accidentally inherits Job A's authenticated user.
3. Enforcing RBAC Inside LLM Tool Definitions
Scenario:
The LLM decides a ticket is resolved and calls the close_ticket tool. The tool updates the database. However, the user who invoked the bot is a "Junior Support" role, and company policy dictates only "Senior Support" can close enterprise tickets.
Why it matters:
LLMs are eager to please. If a user asks, "Can you close this enterprise ticket for me?", the LLM will happily call the tool because the tool exists in its definition. The LLM does not know your company's HR hierarchy.
Solution:
Every tool that mutates state or accesses sensitive data must run through Laravel’s Gate::authorize() before executing its core logic. If the user lacks permission, the tool should throw an exception or return a specific error string that the LLM can read and relay back to the user.
Code:
class CloseTicket
{
public function handle(int $ticketId): string
{
$ticket = Ticket::findOrFail($ticketId);
// This uses the standard Laravel TicketPolicy
Gate::authorize('close', $ticket);
$ticket->update(['status' => 'closed']);
return "Ticket #{$ticketId} has been closed.";
}
}
Why this works:
When Gate::authorize() fails, it throws an AuthorizationException. Your agent orchestration layer should catch this exception and return it to the LLM as a tool result: "Error: You do not have permission to close enterprise tickets." The LLM will then naturally respond to the user: "I'm sorry, but your current role doesn't allow you to close enterprise tickets. Please ask a senior team member."
🧠 The important part:
Do not hide the tool from the LLM based on permissions. If you dynamically remove tools from the system prompt based on roles, you fragment your agent's capabilities and make debugging a nightmare. Let the LLM see the tool, but let the Gate block the execution.
4. Scoping Database Queries with Permission-Aware Eloquent
Scenario:
You have a search_documents tool. The user asks, "Summarize the Q3 financial projections." The tool searches a vector database or runs a full-text SQL search. It returns 10 documents, 3 of which are marked confidential and belong to the executive team.
Why it matters:
Vector similarity search and full-text search do not understand RBAC. They only understand math and text. If you don't apply permission filters to the search query, the agent will ingest confidential data into its context window and summarize it for a junior employee.
Solution:
Use Laravel’s Global Scopes or explicit query scopes to ensure that every search or retrieval tool automatically applies the acting user’s permissions.
Code:
class SearchDocuments
{
public function handle(string $query): array
{
// Assuming a 'searchable' scope and an 'accessibleBy' scope
return Document::search($query)
->accessibleBy(Auth::user())
->get()
->map(fn ($doc) => [
'id' => $doc->id,
'title' => $doc->title,
'excerpt' => $doc->excerpt,
])
->toArray();
}
}
Why this works:
By chaining accessibleBy() (which could be powered by a package like Spatie Permission or a custom trait), the SQL query automatically appends WHERE department_id IN (...) or WHERE visibility = 'public'. The vector search or full-text index only evaluates the pre-filtered, authorized dataset.
⚠️ Gotcha:
If you are using a dedicated Vector Database (like Qdrant or Pinecone) instead of SQL, you must sync your RBAC metadata to the vector payloads. When querying the vector DB, you must pass the user's allowed department IDs as a metadata filter, otherwise the vector DB will happily return restricted embeddings.
5. Handling ABAC and Complex Hierarchies with laravel-permission-manager
Scenario:
Your authorization rules aren't just simple roles. A "Regional Manager" can approve refunds up to $5,000, but only for stores in their region. A "Director" can approve any refund, but cannot approve refunds for their own direct reports (conflict of interest). Standard boolean RBAC (hasRole('manager')) completely breaks down here.
Why it matters:
Most basic permission packages only check if a user has a string-based role. They fail at Attribute-Based Access Control (ABAC), role hierarchies, and contextual denials. If your AI agent needs to navigate complex corporate logic, a basic RBAC check will either over-grant or under-grant access.
Solution:
For enterprise-grade agent permissions, integrate a comprehensive engine like hosseinhezami/laravel-permission-manager. This package is designed specifically for advanced scenarios: Role Hierarchy, Multi-Tenancy, Direct Permissions, and complex Allow/Deny overrides.
Code:
use HosseinHezami\LaravelPermissionManager\Facades\PermissionManager;
class ApproveRefund
{
public function handle(int $refundId): string
{
$refund = Refund::findOrFail($refundId);
$user = Auth::user();
// Evaluate complex ABAC and Hierarchical rules
$canApprove = PermissionManager::user($user)
->withHierarchy() // Respects role inheritance (e.g. Director inherits Manager)
->can('approve', $refund);
if (! $canApprove) {
return "Error: Approval denied. Check amount limits or conflict of interest rules.";
}
$refund->approve();
return "Refund #{$refundId} approved.";
}
}
Why this works:
laravel-permission-manager evaluates the entire permission tree. It checks if the user has the direct permission, if their role inherits the permission, and if any explicit "Deny" rules (like the conflict of interest rule) override the "Allow". The AI agent remains completely agnostic to the complexity; it just asks the Permission Manager, and the Manager enforces the corporate policy.
💡 Practical note:
When using advanced permission engines, cache the user's resolved permission tree at the start of the agent loop. Evaluating deep ABAC hierarchies on every single tool call inside a multi-step ReAct (Reason+Act) loop will destroy your database performance and spike your response latency.
6. The "Read-Only" Illusion and Tool Mutations
Scenario:
You configure your agent with a system prompt: "You are a read-only analytics assistant. Never modify data." You provide it with tools to query sales data, but you accidentally leave the update_customer_status tool in the tool registry. A user asks, "Can you mark my account as VIP?" The LLM, trying to be helpful, calls the update tool.
Why it matters:
System prompts are suggestions, not security boundaries. LLMs suffer from hallucination and instruction drift, especially in long context windows. If a mutation tool is available in the environment, the LLM will eventually use it.
Solution:
Implement strict tool routing based on the user's intent and permission set. If a user only has "Read" permissions for a module, do not even register the "Write" tools in the agent's tool registry for that session.
Code:
class AgentToolRegistry
{
public function getToolsForUser(User $user): array
{
$tools = [
new SearchCustomers(),
new GetSalesReport(),
];
// Dynamically inject mutation tools only if authorized
if (PermissionManager::user($user)->can('update', Customer::class)) {
$tools[] = new UpdateCustomerStatus();
}
if (PermissionManager::user($user)->can('delete', Customer::class)) {
$tools[] = new DeleteCustomer();
}
return $tools;
}
}
Why this works:
This creates a hard perimeter. If the LLM doesn't know the DeleteCustomer tool exists, it cannot hallucinate a JSON payload to invoke it. You combine the flexibility of dynamic tool injection with the absolute security of server-side RBAC.
🔍 Why this matters:
Dynamic tool registries also save tokens. By only injecting the tools relevant to the user's permission level, you shrink the system prompt size, reducing API costs and improving the LLM's tool-selection accuracy.
7. Auditing Agent Actions Without Breaking Context
Scenario:
An AI agent deletes a critical record. You check the audit logs, and the user_id is null or system_worker_1. You have no idea which human user prompted the agent to perform the action.
Why it matters:
In enterprise environments, non-repudiation is a legal requirement. If an AI acts on behalf of a user, the audit trail must reflect the human's identity, the agent's identity, and the reasoning trace that led to the action.
Solution:
Create a custom audit listener or middleware that intercepts tool executions. It must log the acting_user_id, the agent_session_id, the tool_name, the parameters, and the llm_reasoning.
Code:
class AuditAgentAction
{
public function handle(AgentContext $context, string $toolName, array $params, string $reasoning): void
{
ActivityLog::create([
'causer_type' => User::class,
'causer_id' => $context->actingUser->id, // The human
'agent_id' => $context->agentSessionId, // The bot instance
'event' => "tool_executed: {$toolName}",
'properties' => [
'parameters' => $params,
'llm_reasoning' => $reasoning, // Why the LLM decided to call this
],
]);
}
}
Why this works:
When the compliance team asks, "Why was this record deleted?", you can pull the exact chain of thought the LLM generated, alongside the human user who initiated the chat session. It bridges the gap between deterministic database logs and probabilistic AI reasoning.
🚨 Production warning:
Ensure your audit logs redact sensitive parameters before saving. If the LLM calls asearch_userstool with a Social Security Number as a parameter, you do not want that PII stored in plaintext in your activity log table.
8. Architecture Decision Guide: Where to Enforce Permissions
When designing your AI permission layer, you have three primary architectural choices. Choosing the wrong one leads to either massive security holes or unmaintainable spaghetti code.
| Enforcement Layer | How it Works | Pros | Cons | Best Use Case |
|---|---|---|---|---|
| Prompt-Level (System Instructions) | Telling the LLM "Do not access X" via system prompt. | Zero code changes. Fast to implement. | Highly insecure. LLMs hallucinate and ignore rules. | UI suggestions, tone policing. Never for security. |
| Application-Level (Gates/Policies) | Checking Gate::authorize() inside the tool's PHP logic. |
Reuses existing Laravel logic. Strong security. | Requires passing user context to background jobs. | Standard CRUD operations, internal dashboards. |
| Database-Level (Global Scopes/Row-Level Security) | Scoping Eloquent queries or using Postgres RLS. | Impossible to bypass. Highest performance. | Complex to set up. Hard to debug why a record is missing. | Multi-tenant SaaS, massive datasets, vector search filtering. |
| API Proxy / Gateway | An external gateway strips unauthorized tools before they reach the LLM. | Centralized control. Language agnostic. | Disconnects tool logic from Laravel domain models. | Microservices, enterprise API gateways. |
What I Would Choose in a Real Project
For a standard Laravel monolith or modular monolith, Application-Level enforcement (Gates/Policies) backed by a robust package like laravel-permission-manager is the sweet spot.
It allows you to keep your business logic inside your Laravel domain. You don't have to duplicate your permission rules into a separate API gateway or rely on database-level Row-Level Security (which can be a nightmare to maintain alongside Eloquent).
However, if you are building a Multi-Tenant SaaS where an AI agent queries millions of rows across different organizations, you must push the enforcement down to Database-Level Scopes or Vector DB metadata filters. The latency of booting up Eloquent models and running PHP-level Gates on 10,000 retrieved vector embeddings will destroy your agent's response time.
The Final Architectural Observation
The most common mistake developers make when building AI agents is treating the LLM as a trusted internal service. It is not. The LLM is a highly unpredictable, easily manipulated user interface.
By forcing your AI agents to pass through the exact same Gate::authorize(), Eloquent scopes, and hierarchical permission managers as your human users, you stop treating AI security as a separate, special problem. It just becomes standard Laravel authorization, executed in a background queue.
Build the perimeter around the data, not around the prompt. The LLM will eventually try to break the rules; your database should be the one that politely declines.
Top comments (0)