Every multi-tenant application carries a silent time bomb: a single missing WHERE tenant_id = ? clause. It doesn’t throw an exception, doesn’t trigger a test failure and doesn’t appear in your logs. It simply returns another customer’s data to the wrong user. In a SaaS product with hundreds of tenants, that kind of leak can mean a regulatory incident, a breach notification and the end of customer trust, all triggered by a junior developer who forgot to extend the right base class or apply the right ORM scope on a Friday afternoon.
The conventional defenses (Laravel Global Scopes, Doctrine SQL Filters, custom repository base classes, code review checklists) all share the same fundamental weakness: they depend on developers remembering to opt in. Every new query surface, every raw SQL shortcut, every eager-loaded relation is a potential escape hatch. The enforcement lives in application code, which means it can be forgotten, bypassed or silently broken during a refactor. You’re defending a perimeter made of good intentions.
IPC NEWSLETTER
All news about PHP and web development
PostgreSQL’s Row-Level Security moves that enforcement below the application layer entirely. Policies are attached to the table itself, evaluated by the database engine on every query and physically invisible to the connection executing them. When an application sets a session variable identifying the current tenant, the engine filters every SELECT, guards every INSERT, blocks every UPDATE and rejects every DELETE automatically. The filter is attached to the table itself, not to any application-layer convention that a developer has to remember. The application code becomes genuinely tenant-blind: Order::all() returns only this tenant’s orders, because the database won’t surface any other rows.
This article builds that system from scratch. We’ll harden a PostgreSQL schema with non-bypassable RLS policies, provision a restricted database role that can’t sidestep them, wire a PHP middleware to inject tenant context at the transaction boundary and show concrete integration patterns for both Laravel and Symfony. By the end, the same findAll() call that’s dangerous today becomes provably safe, by construction rather than convention.
Why does every query carry a security risk?
At first glance, manual tenant filtering looks perfectly reasonable. A typical PDO query reads like this:
$stmt = $pdo->prepare('SELECT * FROM orders WHERE tenant_id = ? AND status = ?');
$stmt->execute([$tenantId, $status]);
The intent is clear, the filter is visible and a code reviewer scanning that line will tick it off without concern. The danger lies in every query that follows it, written by someone else, on a different day, under deadline pressure, in a repository that has grown to ninety-odd classes.
Consider this function:
function getRecentOrders(PDO $pdo): array
{
// Spot the bug: no tenant_id filter
$stmt = $pdo->query('SELECT * FROM orders WHERE created_at > NOW() - INTERVAL \'7 days\'');
return $stmt->fetchAll();
}
That function will pass static analysis. It will pass PHP-CS-Fixer. In a development environment with a single test tenant it will produce correct-looking output. Deployed to production, it returns the last seven days of orders for every tenant in the database, to whoever called it first.
This exact shape surfaces in production in four distinct, predictable ways.
A new team member adds a reporting method to OrderRepository. They model it on a similar method in UserRepository that happens not to need tenant scoping. The code review focuses on SQL correctness and the return type. Nobody notices the missing WHERE tenant_id = :tenant. The function works flawlessly in the staging environment, which hosts a single tenant’s data. In production, with four hundred tenants, it’s a cross-tenant data leak on every call.
Raw SQL bypasses ORM scopes. Laravel’s Global Scopes and Doctrine’s SQL Filters are powerful conventions, but they’re conventions applied by the ORM’s query builder. The moment a developer reaches for DB::select(), $pdo->query() or a raw migration script for a bulk update, those scopes are silently absent. Nobody disabled them. They were never consulted. Reports, one-off data corrections and migration scripts are precisely the places where developers reach for raw SQL, and they’re exactly the places where tenant isolation is most likely to be skipped. The ORM abstraction leaks, and tenant isolation leaks through it.
Admin dashboard exposes cross-tenant data. Internal tooling accumulates its own pathologies. An admin panel is built quickly, without the same review rigor as customer-facing code, often with the explicit rationale that “admins see everything, so we don’t need to scope it.” That logic is correct for a superadmin viewing a global report, but the unscoped endpoint now exists in the application. A misconfigured route guard, an overly broad role assignment, a leaked session token or a confused permission check can expose that endpoint to a user who should see only their own tenant’s data. The application now has a loaded gun sitting in a drawer labelled “internal only.”
Queue workers, cron jobs and Artisan or Symfony Console commands execute outside the HTTP request lifecycle. Background jobs have no request context to draw from. Tenant context in those environments is typically derived from the job payload, but ORM scopes that rely on auth()->user()->tenant_id or request()->header(‘X-Tenant-ID’) have no request to read from. Depending on the framework and the scope implementation, the filter either throws an exception at runtime (visible, fixable) or silently defaults to no filter (invisible, catastrophic). The second outcome is more common than the first, because developers test queue workers the same way they test everything else: with a single tenant in a clean database.
What all four failure modes share is that the enforcement mechanism lives in application code written by humans. It can be forgotten on the first pass, removed in a refactor or simply not understood by the next person who touches the file. The only reliable defense is one that doesn’t depend on the developer remembering to opt in.
What if the database made this impossible?
How does PostgreSQL enforce tenant boundaries at the engine level?
Row-Level Security is a predicate: an invisible WHERE clause attached directly to a table, evaluated by the query planner as though it were part of the original SQL. It isn’t a view, a trigger or a stored procedure wrapper. The application never writes it, the developer never calls it and the engine always applies it. That distinction matters more than it might initially seem.
Enabling it requires a single DDL statement:
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
This alone is already a breaking change for non-superuser roles: without an explicit policy, they see zero rows. Every query against that table returns an empty result set. The default is deny, not permit. That’s the right default, and it’s why you add the policy next.
The policy itself is where the isolation lives:
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID);
current_setting(‘app.current_tenant_id’, true) reads a session-level configuration variable, not a table or a subquery. The value is written once per connection or transaction, stored in the connection’s runtime state and read from there on every query the engine evaluates. Think of it as a register, not a lookup. There’s no index scan, no network round-trip, no function that can be overridden.
The two defensive wrappers matter. The true second argument (the missing_ok flag) makes current_setting return NULL when the variable isn’t set instead of throwing an error. _NULLIF(…, ”)_catches the other common failure mode: a bug or misconfigured middleware that sets the variable to an empty string, which would otherwise crash on ”::UUID with an “invalid input syntax” error. Together they produce the same safe result in both cases: NULL from the expression, a comparison that’s never true and an empty result set. A loud crash is easy to miss in logs. An empty result set with RLS in place is impossible to mistake for a data leak. This behaviour only applies to tables with RLS enabled. Reference tables, public catalogues and anything else you haven’t opted into RLS on keep working normally regardless of whether the tenant variable is set.
Once the policy is in place, the engine rewrites every query from a non-superuser role transparently. A developer writes:
SELECT * FROM orders WHERE status = 'pending';
The engine executes something equivalent to:
SELECT * FROM orders
WHERE status = 'pending'
AND tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID;
The application sees only the result. The rewrite is invisible at the protocol level. The driver receives rows as if the original query had always contained that filter. The isolation lives below the ORM, below the repository layer, below anything an application author writes or forgets to write.
This is the mechanism that makes the rest of this article possible. Everything that follows (the role configuration, the PHP middleware, the Laravel and Symfony integration patterns) is scaffolding to set that session variable reliably. The hard work is done by the planner.
The role matters: BYPASSRLS
Running your application as a restricted database user, not the superuser, is standard practice for any production deployment, and you’re almost certainly already doing it. Least-privilege roles limit blast radius, prevent accidental schema changes and keep migration tooling separate from runtime access. A typical application role looks like this:
CREATE ROLE app_user WITH LOGIN PASSWORD 'strong_password' NOINHERIT;
GRANT CONNECT ON DATABASE myapp TO app_user;
GRANT USAGE ON SCHEMA public TO app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user;
For RLS, this matters for one specific reason: PostgreSQL superusers and any role with the BYPASSRLS attribute silently skip every RLS policy. The policies still exist, no error is raised, but the engine simply doesn’t evaluate them. This is by design, because DBAs and migration tools need unrestricted access. But it means that if your application were to connect as postgres, every CREATE POLICY statement would be inert. Since you’re already running as a restricted role, this isn’t a trap you’re likely to fall into. Just verify that your application role has neither SUPERUSER nor BYPASSRLS, and the policies will be enforced on every query it executes.
Two practical consequences to remember. Don’t run pg_dump as app_user: RLS filters its reads too, so the dump silently comes back short. Run it as the table owner or a superuser. The same rule applies to migrations, data-repair scripts and anything else that legitimately needs to see across tenants. Give those jobs a role that can bypass the policy, and keep that role out of the application’s connection string.
IPC NEWSLETTER
All news about PHP and web development
How do you prevent a tenant from writing into another tenant’s rows?
Everything covered so far has been about reads. USING defines which rows a role can see, and the engine enforces it silently on every SELECT. But isolation isn’t complete until writes are covered as well. A policy without WITH CHECK leaves a gap that’s easy to miss precisely because the symptom is invisible.
Consider what happens when a bug in the application sets the wrong tenant_id on an INSERT: a hardcoded test UUID left in from development, a copy-paste error in a factory, a race condition that reads the wrong session variable. Without a write constraint, PostgreSQL accepts the row. The USING clause then filters it out of every subsequent SELECT the inserting tenant runs, so from the application’s perspective the insert produced nothing. No exception, no visible side effect, no broken test. Meanwhile the row sits in the database fully visible to the tenant whose UUID was accidentally used. The inserting tenant can’t see the data pollution they just created. The victim tenant receives fabricated data they can’t account for. This is a worse failure mode than a read leak, because neither party sees a signal that anything has gone wrong.
The fix is a second expression on the same policy:
CREATE POLICY tenant_isolation ON orders
FOR ALL
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID)
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::UUID);
WITH CHECK is a constraint evaluated during execution, against the new row values, before the write completes. It runs before the row lands, not after. On INSERT and UPDATE, PostgreSQL tests whether the incoming row would satisfy the expression. If it doesn’t, the statement is aborted immediately with ERROR: new row violates row-level security policy. The row is never written. The transaction rolls back at that point.
The practical consequence is that the silent corruption scenario becomes a loud, visible failure. The same bug that previously produced an undetectable data leak now causes an immediate exception. That exception surfaces in logs, in error monitoring, in the developer’s console during testing. The bug goes from something a team might discover weeks later during a compliance audit to something a developer hits on the first test run. That shift in feedback latency is the difference between a security incident and a caught defect.
The expression in WITH CHECK is deliberately identical to USING. For tenant isolation, the invariant is the same in both directions: a row either belongs to the current tenant or it does not. If your use case ever involves policies where read access and write constraints differ (for example, a role that can read a shared reference table but must never write to it) the clauses can diverge. For the common case of strict per-tenant ownership, keeping them in sync is correct and the intent is self-documenting.
Reads are filtered by USING. Writes are validated by WITH CHECK. The database layer is now a complete isolation boundary, one that doesn’t depend on the application having a good day.
How does the tenant context reach the database without touching business logic?
The application needs to do exactly one thing: tell the database which tenant is active. The entire PHP integration reduces to reliably setting a single session variable.
The Tenant Context
You need somewhere to hold the current tenant ID. A read-only DTO works, an associative array works, a single-property object works. Pick one and stay consistent. The examples below use:
readonly class TenantContext
{
public function __construct(
public string $id,
public string $name,
) {}
}
Populate it once per request from whatever your auth layer gives you (JWT claim, subdomain, header, user lookup) and register it as a request-scoped service. That’s it. Business logic never sees it. The middleware below is the only code that reads from it.
Where does SET LOCAL fit in the request lifecycle?
TenantContext holds the tenant ID. The middleware is what puts it into the database session, and it does this in the only place that matters: at the transaction boundary.
The pattern is straightforward. Intercept every request, begin a transaction, set the session variable, let the request proceed, commit on success, rollback on failure. That’s the entire job. Here is the complete implementation:
final readonly class TenantMiddleware
{
public function __construct(
private PDO $pdo,
private TenantContext $tenant,
) {}
public function handle(callable $next): mixed
{
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare(
"SELECT set_config('app.current_tenant_id', :tenant_id, true)"
);
$stmt->execute(['tenant_id' => $this->tenant->id]);
$response = $next();
$this->pdo->commit();
return $response;
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
}
This is the single point of tenant awareness for HTTP requests. Queue workers and CLI commands need their own entry-point setup (covered in the framework sections below) but the principle is the same: a thin wrapper at the entry point, and everything downstream is tenant-blind.
A note on the SQL. set_config(‘app.current_tenant_id’, value, true) is the function-form equivalent of SET LOCAL app.current_tenant_id = value, with one practical advantage: you can bind parameters into it. SET and SET LOCAL are utility statements, and PostgreSQL rejects them from PREPARE. That breaks anywhere your driver uses real server-side prepared statements, which is the modern default for PDO pgsql. set_config() is a regular function call inside a SELECT, so parameter binding works in every driver configuration. The third argument (is_local) is what makes it transaction-scoped instead of session-scoped.
Why transaction-scoped and not session-scoped? The distinction is the lifetime of the variable. SET (without LOCAL) persists for the entire database session, which means it persists across connection reuse. When a connection pool hands that connection to the next request, it arrives with the previous tenant’s ID still sitting in the session variable. The next tenant’s queries run with a stale, wrong context. RLS filters the wrong rows. The bug is silent.
SET LOCAL scopes the variable to the current transaction. When the transaction ends, whether by commit or rollback, the variable resets automatically. There’s no cleanup step to remember, no risk of a stale value bleeding into the next request. The variable can’t outlive the transaction that set it. The database engine guarantees this, and the guarantee holds regardless of connection pooling, regardless of exceptions, regardless of whatever the application code does between beginTransaction and commit.
The scope of protection this provides becomes clear when you see what the rest of the application doesn’t have to do. Downstream code never mentions tenants. A service that calls SELECT * FROM orders gets only the current tenant’s orders, because the database’s RLS policy reads the session variable set by this middleware and filters everything else out automatically. Business logic stops carrying tenant plumbing. The middleware is the single point of tenant awareness, and every other layer stops needing to care.
The payoff is concrete. Remember getRecentOrders() from earlier, the function that returned seven days of orders for every tenant in the database?
function getRecentOrders(PDO $pdo): array
{
$stmt = $pdo->query('SELECT * FROM orders WHERE created_at > NOW() - INTERVAL \'7 days\'');
return $stmt->fetchAll();
}
That function is unchanged. No modifications, no added WHERE tenant_id = ?, no base class, no scope injection. Run it now, with the middleware setting app.current_tenant_id and the RLS policy in place, and it returns only the current tenant’s recent orders. Run it without the middleware, with the session variable unset, and it returns nothing at all. Either way, no tenant sees another tenant’s data. The bug is gone without touching the buggy code. The database enforces what the developer forgot to write.
This is the architecture the rest of the article builds on. One session variable, one policy and a thin middleware at each entry point. Every query in the application is tenant-safe by construction.
Warning: Connection Pooling
When using PgBouncer in transaction mode (or Swoole’s persistent connection pools) a SET (without LOCAL) persists for the lifetime of the database session, not the request. If the pool reuses that connection for a different tenant, the stale app.current_tenant_id is still set, and RLS will filter on the wrong tenant silently. Always use SET LOCAL inside a BEGIN…COMMIT block, exactly as the middleware above does. SET LOCAL is automatically cleared when the transaction ends, regardless of how the connection is reused afterwards. If you’re running PgBouncer in session mode, plain SET would also be safe, but SET LOCAL works correctly in both modes, so prefer it universally and eliminate the risk entirely.
Performance and indexing
The concern is legitimate. RLS appends a tenant_id filter to every query without you writing it. A reasonable developer looks at that and asks: is the planner doing extra work on every request? The short answer is no, if you index correctly. Without the right indexes, the planner falls back to sequential scans. With them, performance is identical to hand-written WHERE clauses. The overhead sits entirely in whether the predicate can be satisfied by an index.
The fix is straightforward: create composite indexes with tenant_id as the leading column.
-- Before: index on the application column alone
CREATE INDEX idx_orders_status ON orders (status);
-- After: tenant_id leads, planner satisfies both RLS and application filter
CREATE INDEX idx_orders_tenant_status ON orders (tenant_id, status);
CREATE INDEX idx_orders_tenant_created ON orders (tenant_id, created_at);
This is B-tree prefix optimization. An index on (tenant_id, status) is sorted first by tenant_id, then by status within each tenant’s range. A query with WHERE tenant_id = X AND status = Y can drop directly into the correct leaf page and read a narrow, contiguous range. The RLS predicate and the application’s own filter are both covered by the same index structure in a single scan. The planner doesn’t treat the RLS predicate as special. It arrives at query planning time as just another filter condition, analyzed alongside everything else the developer wrote. If the index covers it, it gets used.
You can verify this directly with EXPLAIN ANALYZE:
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending';
-- With RLS active, the planner output shows:
-- Index Scan using idx_orders_tenant_status on orders
-- Index Cond: (tenant_id = 'abc-123'::uuid AND status = 'pending')
-- Rows Removed by Filter: 0
-- Execution Time: 0.045 ms
The index condition includes both columns: tenant_id from the RLS policy and status from the application query. The planner merged them and found a single index that covers both. There’s no additional filter step, no recheck on the heap, no sequential scan of the tenant’s rows. The query touches only the rows it needs.
The key insight is that RLS achieves performance parity with hand-written WHERE clauses once the index is in place. The only real cost is maintaining the composite index, but that’s a cost you’d carry anyway. Any table that partitions data by tenant needs indexes that lead with tenant_id regardless of whether RLS is involved. The policy doesn’t create a new indexing requirement. It just makes the existing requirement visible. Get the indexes right, and RLS adds zero measurable overhead to your queries.
How do you make a username unique per tenant but not globally?
RLS handles isolation at the row-visibility layer. Schema constraints handle integrity at the write layer. The two concerns are independent, and uniqueness is where teams most often get the schema wrong.
The naive approach is a plain unique constraint on the column:
-- Wrong for tenant-facing values
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);
This prevents Tenant A and Tenant B from both having [email protected]._ In almost every multi-tenant SaaS application, that’s the wrong rule. Each tenant is an isolated world. [email protected] signing up with Tenant A has no relationship whatsoever to [email protected] signing up with Tenant B. A global uniqueness constraint treats two separate namespaces as one and causes legitimate registrations to fail with a constraint violation that the application can’t meaningfully explain to the user.
The fix is to scope the constraint to the tenant:
-- Per-tenant uniqueness: same email allowed across tenants
ALTER TABLE users ADD CONSTRAINT uq_users_tenant_email
UNIQUE (tenant_id, email);
PostgreSQL enforces this at write time, before RLS is even consulted. The uniqueness check is scoped to each (tenant_id, email) pair, so the same email address can exist in multiple tenants without conflict. The constraint is still enforced (two users within the same tenant can’t share an email) but the isolation boundary is correct.
When global uniqueness is right. Some values genuinely must be unique across the entire system regardless of tenant. SSO subject identifiers (the sub claim from an identity provider), OAuth client IDs and external API keys issued by your platform all fall into this category. These are system-level identifiers, not tenant-facing values. An SSO sub must map to exactly one user record across all tenants. Scoping it to (tenant_id, sub) would allow the same identity provider subject to be linked to accounts in multiple tenants, which breaks the login flow. For these, a plain UNIQUE(sub) is the correct constraint.
The rule of thumb is: if the value is tenant-facing (usernames, email addresses, URL slugs, invoice numbers, SKUs) scope the constraint to (tenant_id, column). If the value is system-facing (SSO subject IDs, OAuth client IDs, anything issued or verified by infrastructure that has no concept of tenants) keep it globally unique. Applying this distinction consistently means your schema enforces the right invariants at the right boundaries, and uniqueness violations produce errors that are meaningful rather than accidental.
How do existing frameworks plug into this pattern?
The core pattern is framework-agnostic. We proved it with raw PDO. The database does the heavy lifting. The framework just needs to set one session variable. Here’s how Laravel and Symfony do it with minimal glue code.
Laravel / Eloquent
The first thing to notice is what you can remove. If your application uses a BelongsToTenant trait, a global scope that injects WHERE tenant_id = ? into every Eloquent query, or a base model class that enforces tenant filtering, you no longer need any of it. Those mechanisms exist to compensate for the database having no opinion about tenant boundaries. Now that the database enforces isolation through RLS, they’re redundant. Removing them simplifies every model, eliminates the _withoutGlobalScope()_ workarounds that accumulate whenever a legitimate query needs to cross tenant lines and erases an entire category of “did I forget to apply the scope?” bugs. Less code enforcing the same guarantee, at a lower layer.
The replacement is a single middleware class:
class SetTenantContext
{
public function __construct(private TenantContext $tenant) {}
public function handle(Request $request, Closure $next): Response
{
return DB::transaction(function () use ($request, $next) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, true)", [$this->tenant->id]);
return $next($request);
});
}
}
The structure mirrors the raw PDO middleware shown earlier exactly: begin transaction, set the variable with SET LOCAL, let the request proceed, commit or rollback. The only difference is the surface, DB::beginTransaction() and DB::statement() instead of $pdo->beginTransaction()` and `$pdo->prepare(). Laravel’s database abstraction layer calls the same PostgreSQL wire protocol underneath.
Register it globally in bootstrap/app.php and every route in the application is covered automatically. No per-controller annotation, no opt-in attribute, no base controller to extend.
That covers HTTP. Queue workers, Artisan commands and scheduled tasks don’t pass through HTTP middleware, but they still execute database queries. The same SET LOCAL call needs to happen there. For queued jobs, Laravel’s job middleware is the natural hook. Store the tenant_id on the job payload at dispatch time, then apply it at execution time:
{
public function __construct(private string $tenantId) {}
public function handle(object $job, callable $next): void
{
DB::transaction(function () use ($job, $next) {
DB::statement("SELECT set_config('app.current_tenant_id', ?, true)", [$this->tenantId]);
$next($job);
});
}
}
Each job carries its tenant context explicitly, with no reliance on auth() or the request object. A job wires it up by storing the tenant ID as a property and returning the middleware from its middleware() method:
class ProcessOrder implements ShouldQueue
{
public function __construct(
private string $orderId,
private string $tenantId,
) {}
public function middleware(): array
{
return [new ApplyTenantContext($this->tenantId)];
}
public function handle(): void
{
// tenant context is already set — just write business logic
}
}
Artisan commands have no middleware layer. For tenant-scoped commands, the most straightforward approach is a CommandStarting event listener that reads a –tenant option and sets the session variable, or a base command class that wraps execution in a transaction with SET LOCAL. Neither is as clean as the HTTP or job middleware. CLI is the one context where you’re still writing explicit tenant-aware code. The upside is that even if you forget, RLS doesn’t silently return all tenants’ data: without app.current_tenant_id set, the session variable is empty and the policy filters out every row. The failure mode is an empty result set, not a data leak.
The proof is in what Eloquent’s query builder now does without any modification. Order::all(), Order::where(‘status’, ‘pending’)->get(), DB::table(‘orders’)->get()(which bypasses Eloquent entirely and drops to the query builder) and even a raw DB::select(‘SELECT * FROM orders’) all return only the current tenant’s orders. The RLS policy doesn’t distinguish between how the query arrived. It evaluates the session variable against every row, regardless of whether the query came from Eloquent, the query builder or a raw SQL string.
That last point is the one that matters most. The failure modes described at the start of this article (the developer who reaches for DB::select() to write a report, the admin dashboard that skips the ORM for performance) are now safe. Queue jobs are covered by the job middleware. CLI commands require explicit setup, but even if you forget, the failure mode is an empty result set rather than a data leak. In every case, the database enforces the boundary below the application.
Symfony / Doctrine
The same pattern, wired for Doctrine 3.x. Where Laravel intercepts at the HTTP middleware layer, Doctrine provides a lower-level hook (the DBAL Middleware interface) that wraps the database driver itself. The result is identical: one place in the application sets app.current_tenant_id, and nothing else needs to know it exists.
A Doctrine DBAL Middleware sits between the ORM and the underlying driver. You implement two small classes: one that wraps the driver, and one that wraps the connection the driver returns. But first, a wiring detail. Symfony instantiates the Doctrine middleware stack when the kernel boots, which can happen before any request event has populated TenantContext. Injecting TenantContext directly would blow up in CLI contexts (migrations, cache warmup, console commands) where no request ever fires. The fix is to defer resolution behind a small interface:
interface TenantProviderInterface
{
public function getTenantId(): ?string;
}
A concrete implementation reads from wherever the tenant lives in the current context (request attributes, a scoped service, the security token). The driver never touches that logic. It just asks the provider on every connection and proceeds based on what it gets back.
TenantConnectionMiddleware then handles the outer layer:
final class TenantConnectionMiddleware implements Middleware
{
public function __construct(
private readonly TenantProviderInterface $tenantProvider,
) {}
public function wrap(Driver $driver): Driver
{
return new TenantDriver($driver, $this->tenantProvider);
}
}
TenantDriver intercepts connect() and executes SET LOCAL before returning the connection:
final class TenantDriver extends AbstractDriverMiddleware
{
public function __construct(
Driver $driver,
private readonly TenantProviderInterface $tenantProvider,
) {
parent::__construct($driver);
}
public function connect(array $params): Connection
{
$connection = parent::connect($params);
$tenantId = $this->tenantProvider->getTenantId();
if ($tenantId) {
$stmt = $connection->prepare(
"SELECT set_config('app.current_tenant_id', ?, true)"
);
$stmt->execute([$tenantId]);
}
return $connection;
}
}
The null check matters. If the provider returns null (no active request, no populated context), the driver skips the set_config call entirely. Queries against RLS-protected tables return empty result sets, because the policy’s default-deny kicks in. Queries against tables without RLS continue to work normally. The skipped call only affects tables that consult the session variable through a policy. An empty set on the protected tables is the correct failure mode for a query that runs without tenant context.
Register TenantConnectionMiddleware as a service and tag it with doctrine.middleware. Doctrine wires it into every connection automatically.
One other constraint worth noting: SET LOCAL only persists for the duration of the current transaction. For the middleware to have any effect, Doctrine must be operating inside a transaction when the connection is used. In Symfony applications this is typically arranged by wrapping the request in a transaction via a kernel listener or Doctrine’s own transaction middleware. One of those approaches should already be present if you’re using Doctrine seriously, and no additional wiring is needed here.
Messenger workers. The DBAL Middleware hooks at connect() time, but in a long-running worker the connection is established once and reused across messages. That means the DBAL Middleware fires only on the first message. Subsequent messages inherit a connection where SET LOCAL has already been reset by the previous commit. The DBAL Middleware alone isn’t enough here.
The fix is a self-contained Messenger middleware that manages its own transaction boundary, mirroring the Laravel job middleware pattern. Start with a stamp to carry the tenant ID through the transport:
readonly class TenantStamp implements StampInterface
{
public function __construct(public string $tenantId) {}
}
Attach it at dispatch time: $bus->dispatch(new ProcessOrder($orderId), [new TenantStamp($tenantId)]). Then a Messenger middleware reads the stamp, opens a transaction and sets the session variable before the handler runs:
final class TenantMessengerMiddleware implements MiddlewareInterface
{
public function __construct(private Connection $connection) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$stamp = $envelope->last(TenantStamp::class);
if (!$stamp) {
return $stack->next()->handle($envelope, $stack);
}
$this->connection->beginTransaction();
try {
$this->connection->executeStatement(
"SELECT set_config('app.current_tenant_id', ?, true)",
[$stamp->tenantId]
);
$envelope = $stack->next()->handle($envelope, $stack);
$this->connection->commit();
return $envelope;
} catch (\Throwable $e) {
$this->connection->rollBack();
throw $e;
}
}
}
Each message gets its own transaction with a fresh SET LOCAL. When the transaction commits, the variable resets. No tenant bleed between messages, regardless of how long the worker runs.
Console commands. Accept the tenant ID as an argument or option, begin a transaction and execute SET LOCAL before running any queries. Same pattern as every other entry point, just wired manually. Unlike Messenger, there’s no dispatch-side mechanism. The command must receive the tenant ID explicitly. As with the Laravel side, the safe default applies: without app.current_tenant_id set, the policy filters out every row rather than returning all tenants’ data.
What matters is what you no longer write. $repo->findAll() returns only the current tenant’s rows. $repo->findBy([‘status’ => ‘pending’]) returns only the current tenant’s pending rows. DQL queries and the QueryBuilder are both subject to the same RLS policy. Doctrine emits standard SQL and the planner adds the tenant predicate transparently, regardless of the query origin. No custom repository base class, no TenantAwareRepository interface, no Doctrine SQL Filter registered in orm.yaml. The SQL Filters that you may have installed as the traditional Doctrine approach to multi-tenancy can be removed entirely, for the same reason Global Scopes could be removed from Laravel: the enforcement now lives below the ORM, in the database, where it can’t be forgotten, bypassed or quietly absent from a raw query.
IPC NEWSLETTER
All news about PHP and web development
The result
Here is getRecentOrders() again, the function from the opening section, the one that silently returned seven days of orders for every tenant in the database:
function getRecentOrders(PDO $pdo): array
{
// Spot the bug: no tenant_id filter
$stmt = $pdo->query('SELECT * FROM orders WHERE created_at > NOW() - INTERVAL \'7 days\'');
return $stmt->fetchAll();
}
Not a single character has changed. The function is identical to the version that silently returned seven days of orders for every tenant in the database, and it’s now safe. That is the entire point.
Before, security depended on every developer, every query, every time. One forgotten WHERE clause was a data leak. The enforcement lived in application code written by humans under deadline pressure, scattered across repositories, silently absent from raw SQL and invisible to any tool that could reliably check it.
After, security is enforced by the database engine. Your business logic is tenant-blind. There’s one session variable, one policy and a small middleware at each entry point. Every query (whether it comes from Eloquent, Doctrine, the query builder, a raw PDO::query() call or a report written by a contractor who never read the onboarding docs) passes through the same enforcement layer, because that layer sits below all of them.
What you built isn’t large: RLS policies on the tables that need isolation, a restricted role without BYPASSRLS, a thin middleware layer that sets one session variable at each entry point (HTTP requests, queue jobs, CLI commands) and a handful of small framework classes to wire it up. The surface area is small by design. The smaller the surface, the fewer the places a mistake can hide.
By moving isolation to the database layer, you reduce the attack surface for tenant data leaks to a set of SQL policies and a small framework-level setup. Services have no tenant parameters. Repositories have no scoping logic. Event handlers, queued jobs and admin endpoints can’t leak cross-tenant data. Even if a developer forgets to wire the tenant context, the result is an empty set, not another tenant’s rows. The database doesn’t give your code a choice about which rows it can see.
Your application’s only job now is to identify the tenant and hand that identity to the middleware. Everything else runs below your code, at the protocol level, before a single row reaches a PHP process.
Developers will still make mistakes. The database has stopped caring.
Author
🔍 FAQ
1. What is PostgreSQL Row-Level Security in a multi-tenant application?
PostgreSQL Row-Level Security, or RLS, attaches access policies directly to database tables. In a multi-tenant application, those policies can compare each row's tenant_id with the tenant identifier stored in the current database session, automatically restricting which rows an application role can read or modify.
2. Why are application-level tenant filters risky?
Application-level tenant filtering depends on developers consistently adding the correct tenant condition to every query. Raw SQL, new repository methods, background jobs, admin tooling, or ORM queries that bypass a global scope can therefore expose cross-tenant data when the tenant filter is forgotten or unavailable.
3. How does PostgreSQL RLS enforce tenant isolation?
An RLS policy uses a predicate such as tenant_id = current_setting(...) to determine which rows are accessible to the current database session. PostgreSQL evaluates that policy for queries against the protected table, so the tenant restriction does not have to be written into every application query.
4. What happens when the PostgreSQL tenant context is not set?
The article's policy uses current_setting('app.current_tenant_id', true) together with NULLIF so a missing or empty tenant setting produces NULL. The tenant comparison then does not match any protected row, causing queries against RLS-enabled tables to return an empty result rather than another tenant's data.
5. Why should a PostgreSQL application role not have BYPASSRLS?
PostgreSQL superusers and roles with the BYPASSRLS attribute can skip Row-Level Security policies. The runtime application should therefore use a restricted role without SUPERUSER or BYPASSRLS, while administrative tasks that legitimately require cross-tenant access should use a separate privileged role.
6. What does WITH CHECK do in a PostgreSQL RLS policy?
WITH CHECK validates the values of rows being inserted or updated against the RLS policy. For tenant isolation, it can require the row's tenant_id to match the active tenant context, causing PostgreSQL to reject writes that would place data inside another tenant's namespace.
7. Why should SET LOCAL be used for tenant context?
A normal session-level SET can remain active when a database connection is reused, potentially leaving the previous tenant's ID on the connection. Transaction-scoped configuration through SET LOCAL or set_config(..., true) automatically resets when the transaction commits or rolls back, preventing stale tenant context from leaking between requests.
8. How should PostgreSQL RLS be used with connection pooling?
The tenant identifier should be established inside the transaction that executes the tenant's queries. This is particularly important with reused connections such as PgBouncer transaction pooling or persistent PHP connections, because transaction-local settings are cleared when the transaction ends.
9. How should indexes be designed for PostgreSQL Row-Level Security?
For tenant-scoped queries, the article recommends composite indexes with tenant_id as the leading column, such as (tenant_id, status) or (tenant_id, created_at). This allows PostgreSQL's planner to combine the RLS tenant predicate with application filters in the same index scan.
10. How do Laravel and Symfony applications integrate with PostgreSQL RLS?
Laravel can establish tenant context in HTTP or job middleware before application queries execute, while Symfony and Doctrine can establish it through database and Messenger integration points. In both frameworks, the database policy remains responsible for row isolation, allowing repositories, ORM queries, query builders, and raw SQL to operate without duplicating tenant filters.





