Companion Guide: This guide belongs to the Laravel kits. Every Laravel kit ships these checks built in. Explore Kit #1 (Filament), Kit #2 (Livewire), and Kit #3 (MongoDB).
Track 05 Lesson 15

RAG Tenant Isolation in Laravel

Curated by Umar Farooq • 5 min read
Quick Answer & Core Takeaway

Retrieval-Augmented Generation (RAG) tenant isolation guarantees that an AI model only incorporates documents owned by the currently authenticated tenant. Tenant boundaries must be enforced at the retrieval layer (via database and vector search metadata filters), never at generation time via system prompt instructions. Instructing a model to "only discuss user A's data" after feeding it user B's documents is vulnerable to prompt injection and context leakage.

Last reviewed: 2026-10. Applies to: PHP 8.4+, Laravel 11.x – 13.x.

Quick Answer

Retrieval-Augmented Generation (RAG) tenant isolation guarantees that an AI model only incorporates documents owned by the currently authenticated tenant. Tenant boundaries must be enforced at the retrieval layer (via database and vector search metadata filters), never at generation time via system prompt instructions. Instructing a model to "only discuss user A's data" after feeding it user B's documents is vulnerable to prompt injection and context leakage.

Threat Model

Vulnerable Example

[INSECURE] Vulnerable Pattern
<?php

declare(strict_types=1);

namespace App\Services\Ai;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;

class VulnerableRagService
{
    /**
     * [INSECURE]: Vector search retrieves documents without filtering by tenant_id.
     * The developer relies on the prompt to "ignore other users".
     */
    public function queryRag(string $userQuery, int $tenantId): string
    {
        // Vulnerable: Global embedding similarity search across the entire database!
        $embedding = $this->getEmbedding($userQuery);

        $documents = DB::select("
            SELECT content, tenant_id
            FROM document_chunks
            ORDER BY embedding <=> ?::vector
            LIMIT 5
        ", [json_encode($embedding)]);

        // Relying on prompt to filter tenant data is completely bypassable
        $context = collect($documents)->pluck('content')->implode("\n---\n");
        $systemPrompt = "You are an assistant for tenant #{$tenantId}. Only answer questions using data belonging to this tenant.";

        return $this->askLlm($systemPrompt, $userQuery, $context);
    }
}

Secure Implementation

[SECURE] Hardened Pattern
<?php

declare(strict_types=1);

namespace App\Services\Ai;

use App\Models\DocumentChunk;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use RuntimeException;

final class SecureRagTenantIsolationService
{
    /**
     * [SECURE]: Hard tenant isolation enforced strictly at the query/vector retrieval layer.
     */
    public function queryTenantRag(User $user, string $userQuery): string
    {
        $tenantId = $user->tenant_id;
        if ($tenantId === null) {
            throw new RuntimeException('Authenticated user has no associated tenant ID.');
        }

        $queryEmbedding = $this->generateEmbedding($userQuery);

        // Enforce hard tenant scoping directly in PostgreSQL pgvector / MySQL query
        $relevantChunks = DocumentChunk::query()
            ->where('tenant_id', $tenantId) // HARD ISOLATION FILTER
            ->whereHas('document', function (Builder $query) use ($user) {
                // Secondary ACL: Verify user has permission to read this document
                $query->where('visibility', 'public')
                    ->orWhere('owner_id', $user->id);
            })
            ->orderByRaw('embedding <=> ?::vector', [json_encode($queryEmbedding)])
            ->limit(4)
            ->get();

        if ($relevantChunks->isEmpty()) {
            return 'No relevant tenant documentation was found to answer your inquiry.';
        }

        $contextBlocks = $relevantChunks
            ->map(fn (DocumentChunk $chunk) => "<doc id=\"{$chunk->id}\">\n" . htmlspecialchars($chunk->content, ENT_QUOTES, 'UTF-8') . "\n</doc>")
            ->implode("\n");

        return $this->generateGroundedResponse($contextBlocks, $userQuery);
    }

    private function generateEmbedding(string $text): array
    {
        $response = Http::withToken((string) config('services.openai.key'))
            ->post('https://api.openai.com/v1/embeddings', [
                'model' => 'text-embedding-3-small',
                'input' => $text,
            ]);

        return (array) $response->json('data.0.embedding');
    }

    private function generateGroundedResponse(string $sanitizedContext, string $question): string
    {
        $systemPrompt = <<<SYS
You are an enterprise AI assistant.
Answer the user question using ONLY the provided verified document chunks.
If the answer cannot be found directly in the documents, state that clearly.
SYS;

        $userMessage = <<<USR
<verified_tenant_documents>
{$sanitizedContext}
</verified_tenant_documents>

User Question: {$question}
USR;

        $response = Http::withToken((string) config('services.openai.key'))
            ->post('https://api.openai.com/v1/chat/completions', [
                'model' => 'gpt-4o-mini',
                'temperature' => 0.1,
                'messages' => [
                    ['role' => 'system', 'content' => $systemPrompt],
                    ['role' => 'user', 'content' => $userMessage],
                ],
            ]);

        return (string) $response->json('choices.0.message.content');
    }
}

Laravel-Specific Notes

Checklist

Further Reading

AI Assistant Shortcuts
Ask AI about this topic
← Prev: LLM API Key & Cost Security
Next: Agent Tool Sandboxing →
UF

Curated by Umar Farooq

Senior Full-Stack & Security Engineer with 5+ years of production experience in Laravel, PHP, and modern web application security. Author of the Laravel AI Security Masterclass.