SaaS architecture
Laravel and PostgreSQL row-level security, measured
Setting the tenant for PostgreSQL row-level security in Laravel 13, measured: middleware, persistent PDO connections and DB::transaction around a request.
Row-level security in PostgreSQL needs to know the tenant, and the usual way to tell it is a setting on the connection: set_config('app.tenant', …). In Laravel that line goes into a middleware. PHP’s shared-nothing model makes the obvious version safe by default — every request opens its own database connection — and one line in config/database.php takes that safety away.
We measured three middlewares, with and without persistent PDO connections. With Laravel’s defaults, a session-level tenant did not leak, because each request ran on a new backend. With PDO::ATTR_PERSISTENT on, the requests without a tenant counted the previous customer’s invoices. set_config(…, true) in a middleware returned no rows at all. Wrapping the request in DB::transaction() gave the right rows everywhere — but committed the insert of a request that failed, because Laravel turns the exception into a response before the transaction sees it.
The setup
Laravel 13.34.0 on PHP 8.3.6 with pdo_pgsql, served by PHP’s built-in web server with one worker; PostgreSQL 17.10 in a throwaway container. Measured on 4 October 2026.
- One table
invoices, 20 rows for tenant 1 and 10 for tenant 2, row-level security forced, policyusing (tenant_id = nullif(current_setting('app.tenant', true), '')::int). Laravel connects as an ordinary role, not the table owner. - One route that returns the number of invoices and the backend process ID. A middleware reads
?tenant=and, if present, sets it. - Four HTTP requests in a row: tenant 1, no tenant, tenant 2, no tenant. “No tenant” stands for any path that does not set one — a public page, a health check, a route someone forgot to put behind the middleware.
What did the request without a tenant see?
| Middleware | Connection | Tenant 1 | No tenant | Tenant 2 | No tenant |
|---|---|---|---|---|---|
set_config(…, false) | default | 20 | 0 | 10 | 0 |
set_config(…, false) | PDO::ATTR_PERSISTENT | 20 | 20 | 10 | 10 |
set_config(…, true) | default or persistent | 0 | 0 | 0 | 0 |
DB::transaction() + set_config(…, true) | default or persistent | 20 | 0 | 10 | 0 |
Rows counted per request. Bold is another customer’s data; italics is a customer who sees none of their own.
Why does a persistent connection leak?
Because the setting belongs to the database session, and a persistent connection keeps the session. With the default configuration every request got a new PostgreSQL backend — four process IDs for four requests — and a new backend has no tenant. With 'options' => [PDO::ATTR_PERSISTENT => true] all four requests ran on one backend, and the requests without a tenant saw whatever the last one had set.
The PHP manual describes persistent connections as “links that do not close when the execution of the script ends”; the next script in the same process that asks for an identical connection gets it back. We measured one worker process. A persistent connection is kept per process, so under PHP-FPM, which we did not measure, we would expect the leak to follow the worker: which customer a request without a tenant sees would depend on which worker it lands on.
Why does set_config(…, true) return nothing?
Because Laravel runs in autocommit. set_config(…, true) — like SET LOCAL — lasts until the end of the current transaction, and outside DB::transaction() the middleware’s statement is its own transaction. The setting was gone before the route’s query ran.
Why does DB::transaction() commit a failed request?
The obvious fix is a middleware that opens a transaction around the rest of the request:
return DB::transaction(function () use ($request, $next, $tenant) {
DB::statement("select set_config('app.tenant', ?, true)", [$tenant]);
return $next($request);
});
It gave the right rows with and without persistent connections: 20, 0, 10, 0. The setting ends with the transaction, so a persistent connection goes back without a tenant.
Then we added a route that inserts an invoice and throws. The response was a 500, and tenant 2 counted 10 invoices before and 11 after: the insert was committed. Laravel’s documentation is accurate — “if an exception is thrown within the transaction closure, the transaction will automatically be rolled back” — but no exception reaches the closure. Every middleware layer, global or per route, receives the exception as an already-rendered response: Laravel’s pipeline catches it, renders it and attaches it to that response (Illuminate\Routing\Pipeline::handleException). $next($request) returns normally, and DB::transaction() commits. Registered as global middleware, it committed the failed insert just the same.
What works?
The same middleware, with the decision to commit made on the response:
public function handle(Request $request, Closure $next)
{
$tenant = $this->resolveTenant($request); // from the session, the host name, a token …
DB::beginTransaction();
try {
if ($tenant !== null) {
DB::statement("select set_config('app.tenant', ?, true)", [(string) $tenant]);
}
$response = $next($request);
if ($response->exception ?? null) {
DB::rollBack();
} else {
DB::commit();
}
return $response;
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
}
With this middleware the failing route left nothing behind: 10 invoices before, a 500, 10 after. The normal requests gave 20, 0, 10, 0 as before.
The criterion is “an exception was thrown”, not “the status is an error”. abort(403) and a failed validation (a 302) rolled back; a route that returned redirect()->withErrors() or response()->json(…, 500) itself committed. Rolling back on a 4xx exception is usually right, but it also undoes what was written on purpose before the abort() — an audit row, a failed-attempt counter — and anything the exception handler logs through the same connection. Writes that must survive a failure need their own connection.
The imports are Closure, Illuminate\Http\Request and Illuminate\Support\Facades\DB. Symfony’s StreamedResponse and BinaryFileResponse have no exception property; ?? null covers that. A streamed response produces its body after the commit, outside the tenant: its callback counted 0 rows, and anything it writes runs outside the transaction. The same goes for work deferred until after the response.
The usual costs of a transaction per request apply: a request that waits on a slow external service holds the transaction open for that long, row locks are held until the response, and DB::afterCommit callbacks run only at the end of the request. The middleware covers only what runs inside it, so put it on every route group that touches tenant data — the “no tenant” column above is what a route outside the group sees. Queued jobs and Artisan commands do not pass through HTTP middleware and need their own way to set the tenant; a worker that serves every tenant is measured in PostgreSQL job workers: retries, crashes, tenant context. Anything that has to work across tenants needs its own route around the policy; the ways that go wrong are in row-level security in PostgreSQL: where tenants leak.
What does it cost?
Average over 300 HTTP requests alternating between tenant 1 and tenant 2, two runs each. The absolute numbers include booting Laravel on every request, with OPcache off as it is by default for PHP’s built-in server; the differences are what matter:
| Configuration | Per request |
|---|---|
| default connection, session-level setting | 17.6–17.8 ms |
| persistent connection, session-level setting (leaks) | 7.6–7.7 ms |
persistent connection, DB::transaction() | 7.8–7.9 ms |
| persistent connection, middleware above | 8.0–8.1 ms |
The default does not leak a session-level tenant because it opens a new connection for every request, and that cost about 10 ms per request here, on one machine with the database next door; a second set of runs with a different client showed the same gap. Against the leaking variant with persistent connections, the transaction costs no measurable time: the differences, under 0.5 ms, are smaller than the variation between runs. We did not time the middleware on the default connection; it pays the same new connection per request.
What we did not measure
Laravel Octane, which keeps the application and its connections alive between requests — we expect a session-level tenant to leak there the way it does with a persistent connection, but did not measure it — PHP-FPM with several workers, PgBouncer in front of the database (the rules for a pooler are measured in PgBouncer and row-level security), and multi-tenancy packages that separate tenants by database or schema. The same question for other frameworks is measured for Django, SQLAlchemy and Prisma. The timings come from one machine and are for comparison.
Sources: the Laravel 13 documentation on database transactions, the PHP manual on persistent database connections, and the Laravel 13.34.0 source (Illuminate\Routing\Pipeline::handleException, ResponseTrait::withException), read on 4 October 2026. All measurements are our own, on Laravel 13.34.0 and PostgreSQL 17.10, on 4 October 2026.