Laravel multi-tenant RAG fails quietly. A retrieval query that misses its tenant filter returns plausible chunks from another customer, the model paraphrases them into an answer, and no log line ever shows a foreign row. The dependable fix has two layers: an Eloquent global scope that keeps well-behaved code correct, and a PostgreSQL row level security (RLS) policy that makes badly behaved code harmless. This runbook builds both on pgvector, then covers the failure modes that survive a correct policy: filtered index scans, pooled connections, queue workers, and caches. It sits within the broader Laravel AI architecture guides and assumes embeddings are already stored, as set up in the embeddings, pgvector, and RAG baseline. Imports are omitted from the snippets.
Choosing an Isolation Model for Multi-Tenant RAG
Decide this first, because it constrains the index design, the migration workflow, and the failure modes you inherit. Four models are in common use, and they trade isolation strength against operational weight.
| Model | Isolation guarantee | Vector index behavior | Operational cost | Reasonable when |
|---|---|---|---|---|
| Shared table, application scope only | Depends on every query remembering the filter | One index, filter applied after the scan | Lowest | Internal tools, prototypes |
| Shared table, scope plus RLS | Database rejects unscoped reads and cross-tenant writes | One index, filter applied after the scan | Low, plus role and pooling discipline | Most B2B SaaS |
| Partition per tenant (or tenant group) | Same as RLS, plus physical separation | One index per partition, so no cross-tenant filtering | Medium, partition management in migrations | A few tenants hold most rows |
| Database per tenant | Strongest, separate credentials | Fully independent indexes | High, N migrations and N pools | Contractual or regulatory separation |
For most B2B products I would start with a shared table, an application scope, and forced RLS. That is an architectural opinion, not a benchmark. Database-per-tenant multiplies migrations, connection pools, and index builds by your tenant count, and that cost reaches a small team well before a leak does. The signal to move toward partitions is recall, not security. If a handful of tenants own most rows, a shared HNSW index forces every small tenant’s query to search through other tenants’ vectors, which is the problem covered under failure modes below.
If you also run keyword search with Laravel Scout and Meilisearch beside your vectors, that index is a second retrieval path. Its tenant filter is a separate control that needs the same verification.
Where Tenant Boundaries Break in a Retrieval Path
A RAG request crosses five hops, and each one can lose the tenant. The diagram pairs every hop with the check that protects it. Teal marks checks in application code, and coral marks the one enforced by PostgreSQL.
Retrieval Path and Enforcement Points
Treat the database boundary as the primary control and the rest as early warning. An application scope can be bypassed by a raw query, a withoutGlobalScopes() call added for a report, or a job that forgot to set context. The RLS policy survives all three, which is why it is the only coral box. The assertion before prompt assembly is cheap insurance. If the database layer is ever misconfigured, that is where you find out before the model does.
Building the Boundary
The implementation has four parts: a request-scoped tenant context, a table protected by RLS, an Eloquent scope for the happy path, and a retrieval service that fails closed. Each decision is made so that forgetting something produces an error or an empty result, never someone else’s data.
Resolving the Tenant From Identity
Resolve the tenant from the authenticated user, never from a header, route parameter, or request body a client can edit. This also keeps the tenant out of anything the model can influence, so a prompt injection cannot choose a tenant. That concern belongs to PII sanitization and prompt injection defense. Register the middleware alias in bootstrap/app.php.
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'tenant' => \App\Http\Middleware\ResolveTenant::class,
]);
})
// app/Http/Middleware/ResolveTenant.php
final class ResolveTenant
{
public function __construct(private TenantContext $tenant) {}
public function handle(Request $request, Closure $next): Response
{
$tenantId = $request->user()?->tenant_id;
abort_if($tenantId === null, 403, 'No tenant on this identity.');
$this->tenant->set((int) $tenantId);
return $next($request);
}
}
TenantContext is bound with scoped() instead of singleton(). A singleton survives across requests inside a long-lived worker, so a stale tenant would be one missed set() away from a leak. A scoped binding is rebuilt per request. This is one of the state patterns worth understanding in how Octane’s long-lived workers behave.
Its scoped() method is the single choke point for database access. It opens a short transaction, sets app.tenant_id with set_config(..., true) so the value is transaction-local, and enables iterative index scans for that transaction only.
// AppServiceProvider::register()
$this->app->scoped(TenantContext::class);
final class TenantContext
{
private ?int $tenantId = null;
public function set(int $tenantId): void
{
$this->tenantId = $tenantId;
}
public function id(): int
{
return $this->tenantId
?? throw new RuntimeException('No tenant context is active.');
}
public function scoped(Closure $callback): mixed
{
$maxScan = (int) config('rag.max_scan_tuples', 20000);
return DB::transaction(function () use ($callback, $maxScan) {
DB::select("select set_config('app.tenant_id', ?, true)", [(string) $this->id()]);
DB::statement('SET LOCAL hnsw.iterative_scan = relaxed_order');
DB::statement("SET LOCAL hnsw.max_scan_tuples = {$maxScan}");
return $callback();
});
}
}
Schema, Scope, and the RLS Policy
The migration creates the vector column with an HNSW index, a plain index on tenant_id, and the policy. The Laravel search documentation covers the vector column helpers used here.
public function up(): void
{
Schema::ensureVectorExtensionExists();
Schema::create('knowledge_chunks', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->text('content');
$table->vector('embedding', dimensions: 1536)->index(); // match your embedding provider
$table->timestamps();
$table->index('tenant_id');
});
DB::statement('ALTER TABLE knowledge_chunks ENABLE ROW LEVEL SECURITY');
DB::statement('ALTER TABLE knowledge_chunks FORCE ROW LEVEL SECURITY');
DB::statement(<<<'SQL'
CREATE POLICY tenant_isolation ON knowledge_chunks
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
SQL);
}
The pgvector extension itself is maintained in the pgvector GitHub repository.
The policy reads the tenant from the transaction-local setting, and two details matter. First, NULLIF(..., '') guards against a setting that exists but is empty, which can happen on a connection where it was defined earlier. Casting an empty string to bigint would raise an error instead of matching nothing. With no tenant, the comparison is NULL and the policy matches no rows, so the table fails closed. Second, WITH CHECK applies the same rule to inserts and updates, so a job cannot write a chunk for another tenant even by mistake.
FORCE ROW LEVEL SECURITY is the line people forget. Table owners bypass policies by default, so an application connecting as the owner would ignore the policy entirely. FORCE closes that gap, but superusers and roles with BYPASSRLS skip RLS regardless. In practice, run migrations through a privileged connection (php artisan migrate --database=...) and give the application a separate role that owns nothing, lacks BYPASSRLS, and holds only DML grants. Define both in config/database.php.
The Eloquent layer is the fast, readable first line of defense. Its scope calls id(), which throws when no tenant is active. A queued job or Artisan command that forgets to set context fails loudly instead of running an unscoped query.
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
$query->where(
$query->getModel()->qualifyColumn('tenant_id'),
app(TenantContext::class)->id(),
);
});
static::creating(function (Model $model) {
$model->tenant_id ??= app(TenantContext::class)->id();
});
}
}
final class KnowledgeChunk extends Model
{
use BelongsToTenant;
protected $guarded = [];
protected function casts(): array
{
return ['embedding' => 'array'];
}
}
A Retrieval Service That Fails Closed
The service embeds the question first, using the first-party laravel/ai SDK, and only then enters the tenant transaction. Embedding explicitly instead of passing a string to whereVectorSimilarTo is deliberate: it keeps the provider call outside the transaction and lets you retry it. Rate limit and overload exceptions are retried with a short backoff. If they persist, a domain exception tells the caller that retrieval is unavailable. Returning an empty result would make the model answer without context and hide the outage.
final class TenantRetriever
{
public function __construct(private TenantContext $tenant) {}
public function retrieve(string $question, int $limit = 6): Collection
{
$vector = $this->embed($question);
$chunks = $this->tenant->scoped(fn () => KnowledgeChunk::query()
->whereVectorSimilarTo('embedding', $vector, minSimilarity: 0.4)
->limit($limit)
->get());
if ($chunks->contains(fn ($chunk) => (int) $chunk->tenant_id !== $this->tenant->id())) {
throw new TenantLeakDetected('Retrieval returned a foreign chunk.');
}
return $chunks;
}
private function embed(string $text): array
{
try {
return retry(
[250, 1000],
fn () => Embeddings::for([$text])->generate()->embeddings[0],
when: fn (Throwable $e) => $e instanceof RateLimitedException
|| $e instanceof ProviderOverloadedException,
);
} catch (RateLimitedException|ProviderOverloadedException $e) {
Log::warning('Embedding provider unavailable', ['tenant' => $this->tenant->id()]);
throw new RetrievalUnavailable(previous: $e);
}
}
}
The post-query check should never fire. It exists for the day someone connects the app as a superuser in staging. Wire TenantLeakDetected to your alerting so it pages instead of disappearing into a log.
Failure Modes That Survive a Correct Policy
A correct policy does not make retrieval correct. These are the cases where isolation holds but behavior is still wrong, or where a surrounding layer reopens the gap.
Filtered Vector Search Returning Too Few Rows
pgvector applies filters after the approximate index scan. According to the pgvector documentation, with the default hnsw.ef_search of 40, a condition matching 10% of rows leaves about four results on average. In a shared table your tenant filter is exactly that kind of condition, and for a tenant holding a tiny share of the rows it can leave none.
pgvector 0.8.0 added iterative index scans, which keep scanning until enough rows pass the filter or hnsw.max_scan_tuples (20,000 by default) is reached. After that cap the query still returns fewer rows than the limit. That is why scoped() enables them per transaction. It uses relaxed_order because RAG takes the top few chunks and rarely needs strict distance ordering, and strict_order is available if you do.
An RLS policy reaches the planner as another filter condition, so I expect the same behavior. Confirm it with EXPLAIN (ANALYZE) against a realistic spread of tenant sizes instead of assuming. When raising max_scan_tuples stops helping, the documentation suggests raising hnsw.scan_mem_multiplier. Beyond that, partial HNSW indexes for your largest tenants or partitions fix the problem structurally.
Production Pitfall Short or empty retrieval is silent. The model answers from general knowledge and the response still looks confident. Log the chunk count per tenant and alert when one tenant’s empty-retrieval rate diverges from the rest.
Pooled Connections and Held Transactions
Behind PgBouncer in transaction mode, a session-level SET can remain on the server connection after your transaction ends and be inherited by the next client. That is why scoped() uses set_config(..., true) and SET LOCAL, both of which expire with the transaction.
The second rule follows from the first: keep the transaction short. Embed the question before entering scoped() and call the model after leaving it. A transaction held across a multi-second provider call pins a pooled server connection for that whole time. This is reasoning about pool mechanics rather than a measurement from my own systems, but it should shape how you size the pool.
Queue Workers, Octane, and Caches
Queued work has no request, so nothing sets the tenant for you. Serialize the tenant ID into the job and call set() first, instead of relying on whatever the worker last held. Rate limit and overload exceptions from the embedding call are left to fail the attempt so the job’s backoff handles them.
final class IngestDocumentChunks implements ShouldQueue
{
use Queueable;
public int $tries = 3;
public function __construct(
public readonly int $tenantId,
public readonly int $documentId,
) {}
public function backoff(): array
{
return [30, 120, 600];
}
public function handle(TenantContext $tenant): void
{
$tenant->set($this->tenantId);
$body = $tenant->scoped(fn () => Document::query()->findOrFail($this->documentId)->body);
$pieces = ChunkText::split($body); // your chunking strategy
$vectors = Embeddings::for($pieces)->generate()->embeddings;
$tenant->scoped(function () use ($pieces, $vectors) {
foreach ($pieces as $i => $piece) {
KnowledgeChunk::query()->create(['content' => $piece, 'embedding' => $vectors[$i]]);
}
});
}
}
Caches follow the same rule. Any semantic or embedding cache keyed by question text must include the tenant ID in the key. Otherwise tenant B receives tenant A’s cached answer with no database query to catch it. The same reasoning applies to persisted agent memory, which is retrieved context just like chunks and needs the same boundary.
Proving Isolation With a Leak Test
The test seeds two tenants and asserts on the two things that matter: retrieval for tenant A never contains tenant B’s rows, and a query with no tenant context returns nothing. Two environment details decide whether the test means anything.
Production Pitfall Run the suite as the restricted application role. As a superuser or a
BYPASSRLSrole the policy is skipped and the test passes for the wrong reason, which is the most common way an RLS setup looks correct and is not.
Failure Mode Avoid
RefreshDatabasefor this test. It wraps each test in a transaction, soscoped()becomes a savepoint, and a transaction-local setting persists until the outer transaction ends. The fail-closed assertion would then see the previous tenant.DatabaseTruncationavoids that.
uses(DatabaseTruncation::class);
it('never returns another tenant chunk and fails closed without context', function () {
$a = Tenant::factory()->create();
$b = Tenant::factory()->create();
$context = app(TenantContext::class);
$vector = array_fill(0, 1536, 0.02);
foreach ([$a, $b] as $tenant) {
$context->set($tenant->id);
$context->scoped(fn () => KnowledgeChunk::query()->create([
'content' => "private to tenant {$tenant->id}",
'embedding' => $vector,
]));
}
$context->set($a->id);
$results = $context->scoped(fn () => KnowledgeChunk::query()
->whereVectorSimilarTo('embedding', $vector, minSimilarity: 0.1)
->get());
expect($results)->toHaveCount(1)
->and((int) $results->first()->tenant_id)->toBe($a->id);
// Outside scoped(), the policy must match nothing.
expect(DB::select('select count(*) as total from knowledge_chunks')[0]->total)->toBe(0);
});
Summary
Multi-tenant RAG in Laravel is a boundary problem before it is a retrieval problem. Resolve the tenant from identity, carry it in a scoped context, and enforce it twice: an Eloquent scope for correctness and forced RLS for safety. Check it a third time before prompt assembly. Route every tenant-aware database call through one transaction-local helper so pooling, iterative scans, and tenant context stay consistent.
The trade-offs are real. A shared table is the cheapest model to operate but exposes recall problems for small tenants, and RLS adds role discipline and testing constraints that a scope alone does not. Partitions and separate databases are escalations you take when tenant size skew or contracts demand them. In the wider architecture this sits beside persisted memory and ingress security, and all three decide what context a model is allowed to see.
If you run multi-tenant retrieval in production, which layer caught your first real leak: the Eloquent scope, the RLS policy, or a post-retrieval assertion? And how do you keep very small tenants visible to an HNSW scan on a shared table? Share your approach and results in the comments.
Frequently Asked Questions
Is an Eloquent global scope enough to isolate tenants in a RAG pipeline?
No. A global scope only protects queries that go through that model with scopes applied. Raw queries, withoutGlobalScopes(), queued jobs without context, and other code paths bypass it. RLS makes the database refuse unscoped reads and cross-tenant writes, so the scope becomes a convenience layer and not your only control.
Does PostgreSQL row level security work with pgvector HNSW indexes?
The policy is enforced regardless of how rows are found. The practical issue is recall, not security. Filters are applied after the approximate scan, so enable iterative scans (pgvector 0.8.0 or later) and test with small tenants on realistic data.
Why does a small tenant get empty or short retrieval results?
The index scan can stop before it finds enough of that tenant’s vectors. Iterative scans continue until enough rows match or hnsw.max_scan_tuples is reached, which defaults to 20,000. Past that cap, fewer rows than the limit come back. Raise the cap, add partial indexes for large tenants, or partition.
Should I use partitions or a separate database per tenant instead of RLS?
Only when tenant size skew or contractual isolation requires it. Partitions give each tenant group its own vector index, which removes cross-tenant filtering. Separate databases add the strongest isolation at the cost of multiplying migrations and connection pools. For most products, a shared table with RLS is the better starting point.
How do I keep tenant context safe in queued jobs?
Serialize the tenant ID into the job, call set() on the context at the start of handle(), and route every database call through scoped(). Never rely on state left by a previous job on the same worker.
A software architect with 15+ years of experience in the PHP and Laravel ecosystem. Dewald created Origin Main to provide the engineering rigour required to integrate AI into professional, high-concurrency production systems. He writes for developers who care less about "getting it to work" and more about "getting it to last".
