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:
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.
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:
- Query Builder e SQL cru.
DB::table('appointments')eDB::select(...)não passam por global scope nenhum. withoutGlobalScopes(). Alguém tira o scope para resolver outro problema (um soft delete, por exemplo) e leva o filtro de tenant junto.- Joins e subqueries. O scope filtra a tabela do model principal, não a tabela do join.
- Relatórios e exports. Costumam ser escritos em SQL na mão por performance, exatamente onde o scope não chega.
- 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:
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:
USINGfiltra o que pode ser lido, atualizado ou apagado.WITH CHECKvalida o que está sendo gravado. Sem oWITH CHECK, umINSERTcomtenant_iderrado passaria.- O segundo argumento
trueemcurrent_settingfaz a função retornarNULLem vez de erro quando a variável não foi definida.tenant_id = NULLnunca é 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''::bigintestoura erro em toda query. FORCE ROW LEVEL SECURITYfaz 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.
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:
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: 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:
- PgBouncer em modo session para a aplicação. O comportamento fica igual ao de conexão direta, mas o ganho do pool cai bastante.
- 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 umDB::transaction. Funciona com transaction pooling, mas obriga a envolver cada unidade de trabalho numa transação e a repetir oset_configno início de cada uma. - 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. 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_idpara 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:
- Cache e Redis. Chaves como
dashboard:statsprecisam virartenant:{id}:dashboard:stats. Um helper que prefixa automaticamente evita depender da memória de quem escreve. - Storage. Arquivos em
tenants/{id}/..., com URL assinada e checagem de tenant antes de gerar o link. - Rate limiting. Limites por tenant, para um cliente barulhento não derrubar os outros.
- Logs. Inclua
tenant_idno 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.
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:
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:
- Crie o role
app_runtimesemBYPASSRLSe troque a aplicação para ele. Antes disso, nada do resto faz efeito. - Adicione o
set_configno 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. - Habilite RLS tabela por tabela, começando pelas mais sensíveis: dados pessoais, financeiro, documentos.
- Mova relatórios e rotinas que cruzam tenants para a conexão de backoffice, explicitamente.
- 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.