DEV Community

Cover image for Laravel AI SDK v1.0 Needs a Tenant Boundary
Paul-S
Paul-S

Posted on

Laravel AI SDK v1.0 Needs a Tenant Boundary

Authenticated tenant: 41
Authenticated user: 842
Agent tool: FindInvoices
Invoice returned: INV-1098
Invoice tenant: 17
Enter fullscreen mode Exit fullscreen mode

The model found an invoice.

The tool executed successfully. No exception appeared in the logs. The response was factually correct.

It also exposed another customer’s data.

This is the dangerous kind of AI failure because every component appears to work. The problem is not hallucination. It is missing tenant isolation.

The SDK Did Exactly What We Asked

Laravel AI SDK v1.0 gives PHP developers a first-party system for agents, conversation storage, streaming, tool approvals and middleware.

It can decide when to call a tool and validate the arguments against a schema. Laravel also supports runtime provider configuration for tenant-specific API keys.

Those capabilities reduce integration work. They do not decide which records the current tenant is allowed to access.

Consider this tool call:

{
  "tool": "find_invoice",
  "arguments": {
    "customer_id": 842
  }
}
Enter fullscreen mode Exit fullscreen mode

The arguments look valid. But customer_id is not an authorization boundary. IDs may collide across imported datasets, users may belong to multiple workspaces, and a malicious prompt may ask for a different account.

The tenant must come from authenticated application state, never from the prompt or model-generated arguments.

Make Tenant Context Impossible to Omit

Start by representing the current boundary explicitly:

final readonly class TenantContext
{
    public function __construct(
        public int $tenantId,
        public int $userId,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Resolve it after authentication and authorization:

$context = new TenantContext(
    tenantId: $request->user()->current_team_id,
    userId: $request->user()->id,
);

$response = (new SupportAgent($context))
    ->forUser($request->user())
    ->prompt($request->string('message'));
Enter fullscreen mode Exit fullscreen mode

Calling forUser() is important because Laravel can associate stored conversations with a user. It is not the complete boundary when one user can access several tenants or workspaces.

The active tenant must still be authorized independently.

The Model Chooses the Tool, Not the Scope

Pass TenantContext into every data-access tool through its constructor:

use App\Models\Invoice;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;

final class FindInvoices implements Tool
{
    public function __construct(
        private TenantContext $context,
    ) {}

    public function description(): Stringable|string
    {
        return 'Find recent invoices for the authenticated account.';
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'status' => $schema->string(),
        ];
    }

    public function handle(Request $request): Stringable|string
    {
        $invoices = Invoice::query()
            ->where('tenant_id', $this->context->tenantId)
            ->where('user_id', $this->context->userId)
            ->when(
                $request['status'] ?? null,
                fn ($query, $status) => $query->where('status', $status)
            )
            ->select(['id', 'status', 'total', 'issued_at'])
            ->latest()
            ->limit(10)
            ->get();

        return $invoices->toJson();
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice what is missing from the schema: tenant_id.

The model cannot supply it, replace it or persuade the tool to ignore it. Even a successful prompt injection remains trapped inside the authenticated tenant boundary.

Laravel’s own guidance for production-safe database tools recommends the same underlying pattern: pass identity through application code, restrict visible columns, cap results and use a read-only database connection where possible.

Five Places Tenant Data Can Still Escape

Fixing the Eloquent query is necessary, but AI applications move data through more than one layer.

Check these boundaries:

  1. Conversation history: Authorize both the user and active tenant before loading an existing conversation ID.
  2. Vector retrieval: Store tenant_id with every chunk and require it as a server-side search filter.
  3. Cache entries: Include the tenant, user, model and permission version in the cache key.
  4. Queued agents: Serialize immutable IDs, then reload and reauthorize them when the worker executes.
  5. Tool approvals: Bind an approval to the tenant, user, tool and original arguments. Reject it if any context changed while the request was waiting.

A cached answer generated for Tenant 41 must never be reusable by Tenant 17 simply because both asked the same question.

Write the Test Before the Prompt

The most useful test is not “Does the agent find an invoice?”

It is “Can the agent retrieve an invoice belonging to someone else?”

it('cannot return another tenant invoice', function () {
    $context = new TenantContext(
        tenantId: $this->tenantA->id,
        userId: $this->userA->id,
    );

    $foreignInvoice = Invoice::factory()->create([
        'tenant_id' => $this->tenantB->id,
    ]);

    $result = (new FindInvoices($context))
        ->handle(new Request(['status' => $foreignInvoice->status]));

    expect($result)->not->toContain($foreignInvoice->id);
});
Enter fullscreen mode Exit fullscreen mode

Add adversarial cases for guessed IDs, reused conversation IDs, poisoned retrieval metadata, delayed approvals and queue jobs executed after membership is revoked.

A Tenant Boundary Is Application Code

Laravel AI SDK v1.0 makes agents feel native to Laravel. That is valuable because tools, queues, models and tests can remain inside the same application.

The abstraction should not hide the security model.

The best Laravel development company for a multi-tenant AI product should be able to identify where tenant identity originates, how it reaches every tool and what prevents the model from changing it.

A prompt can tell an agent to respect tenant boundaries.

Only application code can enforce them.

What tenant-isolation test would you run before connecting a Laravel agent to production data?

Top comments (0)