Pular para o conteúdo
Performance

N+1 no Laravel: como identificar e corrigir de verdade

O N+1 é a causa mais comum de lentidão em aplicações Laravel. Como detectá-lo antes da produção, corrigir com eager loading e impedir que volte.

Nathan Almeida 4 min de leitura

Toda aplicação Laravel que fica lenta conforme a base cresce tem, com altíssima probabilidade, um problema de N+1 em algum lugar. É a causa número um de degradação de performance em aplicações que usam ORM.

E o mais traiçoeiro: em desenvolvimento ele é invisível. Com 10 registros de teste, 11 consultas rodam em 8 milissegundos. Ninguém percebe. Em produção, com 50 itens por página e 200 usuários simultâneos, a mesma tela derruba o banco.

O que é, concretamente

// Controller
$pedidos = Pedido::latest()->take(50)->get();  // 1 consulta

// View
@foreach ($pedidos as $pedido)
    {{ $pedido->cliente->nome }}   {{-- +1 consulta por pedido --}}
@endforeach

Total: 51 consultas onde 2 bastariam.

O código está correto e legível. É exatamente esse o problema: o Eloquent esconde o custo tão bem que a versão ruim é a mais natural de escrever.

E o custo real não é a soma do tempo das consultas. É que cada uma segura uma conexão do pool, atravessa a rede e faz o banco planejar a execução. Cinquenta idas e voltas de rede custam muito mais do que cinquenta vezes o tempo de uma consulta.

Como detectar

1. Prevenção de lazy loading (a mais eficaz)

Esta é a mudança de maior impacto por menor esforço. Em AppServiceProvider:

use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // Em produção, lançar exceção derrubaria a aplicação por um
    // problema de performance — o que é pior que a lentidão.
    Model::preventLazyLoading(! app()->isProduction());
}

A partir daí, qualquer acesso lazy a relacionamento lança LazyLoadingViolationException em ambiente local e de teste. O N+1 deixa de ser um problema silencioso e passa a ser um erro que trava o desenvolvimento até ser corrigido.

Se a suíte de testes cobre as rotas principais, o CI passa a detectar N+1 automaticamente em cada pull request.

2. Contar consultas nos testes

DB::enableQueryLog();

$this->get('/pedidos')->assertOk();

$this->assertLessThan(10, count(DB::getQueryLog()));

Um teste assim vira um orçamento de consultas. Quando alguém adicionar um relacionamento sem eager loading, o teste falha antes do merge.

3. Ferramentas em desenvolvimento

Laravel Debugbar ou Telescope mostram a contagem de consultas por requisição. Útil para investigar, mas depende de alguém olhar — por isso as duas abordagens anteriores são mais confiáveis.

Como corrigir

Eager loading

$pedidos = Pedido::with('cliente')->latest()->take(50)->get();
// 2 consultas, independentemente do número de pedidos

Relacionamentos aninhados e múltiplos:

$pedidos = Pedido::with([
    'cliente',
    'itens.produto',
    'pagamentos',
])->get();

Carregue só as colunas que você usa

Trazer o registro inteiro do cliente para exibir apenas o nome desperdiça memória e banda:

// A chave estrangeira precisa estar no select, senão o Eloquent
// não consegue associar o relacionamento ao registro pai.
$pedidos = Pedido::with('cliente:id,nome')->get();

Contagem: use withCount

// ❌ Carrega todos os itens na memória só para contá-los
$pedido->itens->count();

// ✅ Uma subconsulta, sem trazer os itens
$pedidos = Pedido::withCount('itens')->get();
$pedidos->first()->itens_count;

O mesmo vale para agregações: withSum, withAvg, withMax.

Carregamento condicional

Quando você já tem a coleção e só às vezes precisa do relacionamento:

// Carrega apenas o que ainda não foi carregado
$pedidos->loadMissing('cliente');

Eager loading padrão — com cuidado

class Pedido extends Model
{
    protected $with = ['cliente'];
}

Funciona, mas use com parcimônia. Todo Pedido::find() da aplicação passa a carregar o cliente, inclusive nos casos em que ele não é usado. Costuma ser melhor declarar o with em cada consulta.

O caso que eager loading não resolve

Existe uma armadilha comum: filtrar uma coleção já carregada dispara consultas mesmo com eager loading.

// ❌ where() sobre coleção carregada volta ao banco
foreach ($pedidos as $pedido) {
    $ativos = $pedido->itens()->where('ativo', true)->get(); // N+1
}

// ✅ Restrinja o relacionamento na hora de carregar
$pedidos = Pedido::with(['itens' => fn ($q) => $q->where('ativo', true)])->get();

foreach ($pedidos as $pedido) {
    $ativos = $pedido->itens; // já em memória
}

A diferença é sutil e frequente: $pedido->itens acessa a coleção carregada; $pedido->itens() inicia uma nova consulta.

Impedir que volte

Corrigir o N+1 de hoje é fácil. Impedir o de daqui a três meses exige processo:

  1. preventLazyLoading() ativo em local e teste — o problema vira erro visível
  2. Testes de rota com orçamento de consultas nas telas de listagem críticas
  3. Item na revisão de código: todo with() novo é intencional, todo relacionamento novo em view foi verificado
  4. Monitoramento em produção com APM, alertando quando a contagem de consultas por requisição sobe

Sem isso, o N+1 volta — porque a versão ruim continua sendo a mais natural de escrever.

Quando o problema não é N+1

Vale registrar: nem toda lentidão é N+1. Se depois de corrigir tudo isso a aplicação continuar lenta, os suspeitos seguintes são índice ausente na coluna de filtro ou ordenação, trabalho síncrono que deveria estar em fila, e ausência de cache em dados que quase não mudam.

Cobrimos essas frentes em otimização de performance — mas a ordem importa: sempre medir antes de otimizar, porque a intuição do time costuma apontar para o lugar errado.

Perguntas frequentes

  • O que é o problema N+1 no Eloquent?

    É quando o código executa uma consulta para buscar uma lista de registros e depois uma consulta adicional para cada registro da lista, ao acessar um relacionamento. Uma listagem de 50 pedidos que acessa o cliente de cada pedido dispara 51 consultas em vez de 2. O ORM torna isso invisível no código, o que faz o problema passar despercebido até a base crescer.

  • Como detectar N+1 antes de ir para produção?

    Ative Model::preventLazyLoading() no ambiente local e de testes. O Laravel passa a lançar exceção sempre que um relacionamento for carregado de forma lazy, o que transforma um problema silencioso de performance em um erro impossível de ignorar durante o desenvolvimento.

  • Eager loading resolve todos os casos de N+1?

    Resolve a maioria, mas não todos. Para contagem de relacionamento, use withCount em vez de carregar a coleção inteira. Para agregações, use withSum ou withAvg. E quando só um campo do relacionamento é necessário, selecionar apenas as colunas usadas evita trazer registros inteiros à memória sem necessidade.

Tem um projeto ou um sistema que precisa de atenção?

A primeira conversa é um diagnóstico técnico, não uma reunião de vendas. Você sai dela com uma leitura honesta do problema e uma faixa de investimento — mesmo que não feche com a gente.

  • Resposta em até 1 dia útil
  • Sem compromisso
  • NDA se você precisar