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:

  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:

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.

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:

  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. 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.

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:

  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.