# Multi-tenancy no Laravel com PostgreSQL Row-Level Security: como isolar os dados de cada cliente

> Global scope sozinho não impede vazamento entre tenants. Veja como combinar Eloquent com Row-Level Security do PostgreSQL, filas, Octane e PgBouncer sem furos.

Por Lucas Roberto · Arquitetura · publicado em 2026-10-17 · 11 min de leitura
Versão web: https://lucasroberto.com/blog/multi-tenancy-no-laravel-com-postgresql-row-level-security-como-isolar-os-dados-de-cada-cliente

Para isolar os dados de cada cliente num SaaS Laravel com banco compartilhado, eu uso duas camadas. A primeira é um global scope no Eloquent, que filtra por `tenant_id` por conveniência. A segunda é o Row-Level Security (RLS) do PostgreSQL, que barra no próprio banco qualquer linha de outro tenant, mesmo quando o código esquece o filtro. O scope evita o erro comum. O RLS garante que o erro raro não vire vazamento de dados.

Esse desenho vale para qualquer SaaS onde vários clientes dividem as mesmas tabelas: paróquias num sistema de gestão, estabelecimentos num sistema de agendamento, empresas numa conta digital PJ. O resto do post mostra a implementação, os pontos onde ela costuma quebrar e os trade-offs.

## Banco por tenant, schema por tenant ou tabela compartilhada?

*Tenant* é o cliente isolado dentro do SaaS: a empresa, a paróquia, o estabelecimento. *Multi-tenancy* é servir todos eles com a mesma aplicação. As três estratégias clássicas:

| Estratégia | Isolamento | Custo operacional | Quando faz sentido |
|---|---|---|---|
| Banco por tenant | Máximo | Alto: migrations em N bancos, N conexões, backup por cliente | Poucos clientes grandes, exigência contratual de isolamento físico |
| Schema por tenant (PostgreSQL) | Alto | Médio: migrations em N schemas, `search_path` por request | Dezenas a centenas de clientes médios |
| Tabela compartilhada com `tenant_id` | Depende do código (ou do RLS) | Baixo: um banco, uma migration, relatórios simples | Muitos clientes pequenos, que é o caso típico de SaaS para pequeno negócio |

Nos produtos que atendem pequeno e médio negócio, a terceira opção ganha quase sempre. Rodar migration em milhares de schemas não escala bem, e relatório consolidado entre tenants fica trivial. O preço é que o isolamento deixa de ser estrutural e passa a depender de toda query lembrar do `where tenant_id = ?`. O RLS existe para tirar esse peso das costas do código.

## Por que global scope no Eloquent não basta?

O ponto de partida padrão é um contexto de tenant e uma trait com global scope:

```php
final class TenantContext
{
    private ?int $tenantId = null;

    public function set(int $tenantId): void
    {
        $this->tenantId = $tenantId;
    }

    public function id(): ?int
    {
        return $this->tenantId;
    }

    public function require(): int
    {
        return $this->tenantId ?? throw new TenantNotResolved();
    }
}

// AppServiceProvider::register()
$this->app->scoped(TenantContext::class);
```

O `scoped` importa. Ele faz o container descartar a instância a cada novo request no Octane e a cada novo job no worker de fila. Com `singleton`, o tenant do request anterior sobrevive no processo.

```php
trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $query) {
            $query->where(
                $query->getModel()->qualifyColumn('tenant_id'),
                app(TenantContext::class)->require()
            );
        });

        static::creating(function (Model $model) {
            $model->tenant_id ??= app(TenantContext::class)->require();
        });
    }
}
```

Isso cobre o caminho feliz. Os furos aparecem em lugares previsíveis:

1. **Query Builder e SQL cru.** `DB::table('appointments')` e `DB::select(...)` não passam por global scope nenhum.
2. **`withoutGlobalScopes()`.** Alguém tira o scope para resolver outro problema (um soft delete, por exemplo) e leva o filtro de tenant junto.
3. **Joins e subqueries.** O scope filtra a tabela do model principal, não a tabela do join.
4. **Relatórios e exports.** Costumam ser escritos em SQL na mão por performance, exatamente onde o scope não chega.
5. **Jobs e comandos agendados.** Rodam fora do request, e o contexto de tenant simplesmente não existe ali.

O `require()` lançando exceção ajuda, porque falha alto em vez de retornar tudo. Mas ele só protege quem passa pelo Eloquent. Para o resto, a proteção precisa estar no banco.

## Como funciona o Row-Level Security do PostgreSQL?

*Row-Level Security* é um recurso do PostgreSQL que aplica uma política (uma expressão booleana) a cada linha lida ou escrita numa tabela. Linhas que não passam na política ficam invisíveis para o `SELECT` e não podem ser gravadas por `INSERT` ou `UPDATE`. O banco adiciona a condição a toda query, venha ela do Eloquent, do Query Builder ou de um `psql` aberto por engano.

O tenant chega ao banco por uma variável de sessão customizada, lida com `current_setting`. A migration:

```php
public function up(): void
{
    DB::statement('ALTER TABLE appointments ENABLE ROW LEVEL SECURITY');
    DB::statement('ALTER TABLE appointments FORCE ROW LEVEL SECURITY');

    DB::statement(<<<'SQL'
        CREATE POLICY tenant_isolation ON appointments
            USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
            WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::bigint)
    SQL);
}
```

Cada detalhe ali tem motivo:

- `USING` filtra o que pode ser lido, atualizado ou apagado. `WITH CHECK` valida o que está sendo gravado. Sem o `WITH CHECK`, um `INSERT` com `tenant_id` errado passaria.
- O segundo argumento `true` em `current_setting` faz a função retornar `NULL` em vez de erro quando a variável não foi definida. `tenant_id = NULL` nunca é verdadeiro, então **sem contexto, nenhuma linha aparece**. É o comportamento fail-closed que eu quero.
- O `NULLIF(..., '')` trata o caso em que a variável foi resetada para string vazia. Sem ele, o cast `''::bigint` estoura erro em toda query.
- `FORCE ROW LEVEL SECURITY` faz a política valer também para o dono da tabela. Sem isso, o dono ignora o RLS em silêncio.

## Quais roles do banco eu preciso para o RLS funcionar?

Superusers e roles com `BYPASSRLS` ignoram qualquer política. Se a aplicação conecta como `postgres`, o RLS está ligado e não protege nada. Você só descobre isso no dia em que precisava dele. Eu separo três roles:

| Role | Uso | Permissões |
|---|---|---|
| `app_migrator` | Rodar migrations | Dono das tabelas, DDL |
| `app_runtime` | Aplicação web e workers | Só `SELECT/INSERT/UPDATE/DELETE`, sujeito ao RLS |
| `app_backoffice` | Conciliação, relatórios entre tenants, suporte | `BYPASSRLS`, conexão separada |

No `config/database.php` isso vira duas conexões (`pgsql` e `pgsql_backoffice`), e as migrations rodam com credenciais próprias no deploy. A conexão de backoffice é explícita: quem precisa ver todos os tenants escreve `DB::connection('pgsql_backoffice')` e assume isso no code review. Cruzar tenants passa a ser uma decisão visível no diff, e não o efeito colateral de um filtro esquecido.

## Como passar o tenant do Laravel para o PostgreSQL em cada request?

Um middleware resolve o tenant (subdomínio, header, usuário autenticado) e grava nos dois lugares: no `TenantContext` e na sessão do banco.

```php
final class SetTenant
{
    public function __construct(
        private TenantResolver $resolver,
        private TenantContext $context,
    ) {}

    public function handle(Request $request, Closure $next): Response
    {
        $tenant = $this->resolver->fromRequest($request);

        $this->context->set($tenant->id);
        DB::select("select set_config('app.tenant_id', ?, false)", [(string) $tenant->id]);

        return $next($request);
    }

    public function terminate(Request $request, Response $response): void
    {
        DB::select("select set_config('app.tenant_id', '', false)");
    }
}
```

Uso `set_config` em vez de `SET app.tenant_id = ...` porque `set_config` aceita parâmetro com bind. `SET` não aceita, e montar o comando com interpolação de string é pedir SQL injection. O terceiro argumento `false` define a variável para a sessão inteira, não só para a transação atual.

No PHP-FPM clássico, cada request tende a abrir uma conexão nova e o reset no `terminate` é só higiene. No **Laravel Octane**, o processo e a conexão sobrevivem entre requests, e o reset passa a ser obrigatório. Sem ele, o próximo request que não resolver tenant (uma rota pública, por exemplo) herda o tenant do anterior.

## Como manter o tenant em jobs de fila e comandos agendados?

Worker de fila é um processo de longa duração, então tem o mesmo problema do Octane, só que pior: um único worker processa jobs de tenants diferentes em sequência. O job precisa carregar o `tenant_id` no payload, e um job middleware monta e desmonta o contexto:

```php
final class WithTenant
{
    public function handle(object $job, Closure $next): void
    {
        app(TenantContext::class)->set($job->tenantId);
        DB::select("select set_config('app.tenant_id', ?, false)", [(string) $job->tenantId]);

        try {
            $next($job);
        } finally {
            DB::select("select set_config('app.tenant_id', '', false)");
        }
    }
}

final class SendAppointmentReminder implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public int $tenantId,
        public int $appointmentId,
    ) {}

    public function middleware(): array
    {
        return [new WithTenant()];
    }

    public function handle(): void
    {
        $appointment = Appointment::findOrFail($this->appointmentId);
        // ...
    }
}
```

Repare que o job recebe IDs, não models. Com `SerializesModels`, o model seria re-hidratado antes do middleware rodar, ou seja, sem contexto de tenant. O RLS devolveria zero linhas e o job falharia com `ModelNotFoundException`. Passar IDs e buscar dentro do `handle` resolve isso, e evita de quebra serializar estado velho. Esse tipo de bug de ordem de execução lembra o do [job disparado dentro de transação antes do commit](/blog/jobs-disparados-dentro-de-transacao-o-bug-silencioso-do-aftercommit-no-laravel): o job roda num momento em que o mundo ainda não está como você imagina.

Para comandos agendados que varrem todos os tenants, existem dois caminhos. Ou o comando itera os tenants e despacha um job por tenant (o que eu prefiro, porque isola falhas e paraleliza), ou usa a conexão de backoffice de forma explícita.

## E se eu usar PgBouncer em modo transaction?

Aqui o desenho acima quebra. *PgBouncer* é um pool de conexões para PostgreSQL. No modo *transaction pooling*, cada transação pode cair numa conexão física diferente do banco. Uma variável definida com `set_config(..., false)` fica presa na conexão física onde rodou, não na sua. Duas coisas ruins acontecem: o seu próximo comando pode cair numa conexão sem a variável (zero linhas, fail-closed, mas quebra o app) ou numa conexão onde outro cliente deixou o tenant dele (vazamento).

As opções que conheço, com seus custos:

1. **PgBouncer em modo session para a aplicação.** O comportamento fica igual ao de conexão direta, mas o ganho do pool cai bastante.
2. **Variável local à transação.** Usar `set_config('app.tenant_id', ?, true)`, que vale só até o fim da transação, e garantir que toda query rode dentro de um `DB::transaction`. Funciona com transaction pooling, mas obriga a envolver cada unidade de trabalho numa transação e a repetir o `set_config` no início de cada uma.
3. **Conexão direta sem pooler** para a aplicação web, e pooler só onde não há RLS envolvido.

Na prática, a opção 2 encaixa bem quando a aplicação já trabalha com unidade de trabalho explícita. Num sistema financeiro, quase toda escrita já roda em transação por causa de [locks no saldo e controle de concorrência](/blog/como-evitar-race-condition-no-saldo-da-conta-digital-com-laravel-e-postgresql). O que não dá é ligar transaction pooling sem revisar esse ponto, porque o RLS deixa de ser proteção e vira fonte de bug intermitente.

## O RLS deixa as queries mais lentas?

O PostgreSQL injeta a condição da política na query, e o planner a trata como qualquer outro `WHERE`. O custo real depende de índice:

- Todo índice composto de tabela multi-tenant deve começar por `tenant_id`: `(tenant_id, scheduled_at)`, e não `(scheduled_at)`.
- Constraints de unicidade precisam incluir o tenant: `UNIQUE (tenant_id, document_number)`. Sem isso, dois clientes não podem cadastrar o mesmo valor, e a mensagem de erro de violação ainda revela que o valor já existe em outro tenant.
- Mantenha a expressão da política simples. Subquery dentro de política (por exemplo, checar a tabela de membros a cada linha) custa caro em tabela grande. Se o usuário pode acessar vários tenants, resolva isso na aplicação e mande um único `tenant_id` para o banco.

Rode `EXPLAIN ANALYZE` conectado como `app_runtime`, com a variável setada, e não como superuser. Como superuser o RLS é ignorado e o plano que você vê não é o de produção.

## O que mais precisa ser isolado além do banco?

O RLS protege tabela. Outras camadas guardam dados do tenant e vazam do mesmo jeito:

1. **Cache e Redis.** Chaves como `dashboard:stats` precisam virar `tenant:{id}:dashboard:stats`. Um helper que prefixa automaticamente evita depender da memória de quem escreve.
2. **Storage.** Arquivos em `tenants/{id}/...`, com URL assinada e checagem de tenant antes de gerar o link.
3. **Rate limiting.** Limites por tenant, para um cliente barulhento não derrubar os outros.
4. **Logs.** Inclua `tenant_id` no contexto do log (`Log::withContext`) para investigar incidentes, e cuide do que vai junto. Dado pessoal em log é outro tipo de vazamento, que trato no post sobre [criptografar CPF no Laravel sem perder a busca](/blog/como-criptografar-cpf-no-laravel-sem-perder-a-busca-encrypted-cast-e-blind-index).

## Como testar o isolamento entre tenants?

Os testes precisam rodar com o role `app_runtime`, senão você testa um banco sem RLS. Os três testes que considero obrigatórios:

```php
it('não enxerga dados de outro tenant nem com query crua', function () {
    $a = Tenant::factory()->create();
    $b = Tenant::factory()->create();

    actingAsTenant($a);
    Appointment::factory()->create();

    actingAsTenant($b);
    expect(DB::table('appointments')->count())->toBe(0);
});

it('retorna zero linhas sem contexto de tenant', function () {
    DB::select("select set_config('app.tenant_id', '', false)");
    expect(DB::table('appointments')->count())->toBe(0);
});

it('rejeita insert com tenant_id de outro cliente', function () {
    [$a, $b] = Tenant::factory()->count(2)->create();
    actingAsTenant($a);

    DB::table('appointments')->insert(['tenant_id' => $b->id /* ... */]);
})->throws(QueryException::class);
```

O primeiro teste usa `DB::table` de propósito, porque é o caminho que o global scope não cobre. Se ele passa, o RLS está fazendo o trabalho dele. Também vale um teste de arquitetura que falhe se alguma tabela com coluna `tenant_id` não tiver RLS habilitado. Dá para consultar `pg_class.relrowsecurity` e `pg_class.relforcerowsecurity`, e isso pega a migration nova que alguém esqueceu de proteger.

## Conclusão: por onde começar num SaaS que já está em produção

Se você já tem tabelas compartilhadas com `tenant_id` e só global scope, dá para migrar sem big bang:

1. Crie o role `app_runtime` sem `BYPASSRLS` e troque a aplicação para ele. Antes disso, nada do resto faz efeito.
2. Adicione o `set_config` no middleware e no job middleware, com o reset no final. Rode assim por um tempo sem política nenhuma, só para garantir que todo caminho seta o contexto. Logar quando uma query roda sem tenant ajuda a achar os buracos.
3. Habilite RLS tabela por tabela, começando pelas mais sensíveis: dados pessoais, financeiro, documentos.
4. Mova relatórios e rotinas que cruzam tenants para a conexão de backoffice, explicitamente.
5. Só então considere PgBouncer em modo transaction, já com a estratégia de variável local à transação.

O global scope continua ali, porque deixa o código limpo e dá erro cedo. A diferença é que ele deixa de ser a última linha de defesa. Quando alguém escrever um `DB::select` às pressas num relatório de fim de mês, o PostgreSQL devolve zero linhas em vez dos dados do cliente errado.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
