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:
preventLazyLoading()ativo em local e teste — o problema vira erro visível- Testes de rota com orçamento de consultas nas telas de listagem críticas
- Item na revisão de código: todo
with()novo é intencional, todo relacionamento novo em view foi verificado - 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.