Eloquent e o problema N+1: como detectar, medir e eliminar
A consulta que vira 500 queries sem ninguém perceber. Como identificar o N+1 no Laravel, corrigir com eager loading, tratar os casos que `with()` não resolve — e reconhecer as vezes em que carregar tudo de uma vez piora.
▸Neste artigo
O N+1 é o bug de performance mais comum que já vi em projeto Laravel — e o mais fácil de escrever sem perceber. Ele não quebra nada. Não gera exceção. Passa nos testes. A página só fica lenta, e ninguém consegue apontar exatamente onde.
A causa é sempre a mesma: o Eloquent torna tão natural navegar por relações que
você escreve $pedido->cliente->nome dentro de um foreach sem lembrar que
aquilo é uma consulta ao banco. Multiplique por 200 pedidos e você tem 201
queries onde deveria haver duas.
Este artigo é o processo que eu uso: detectar, corrigir, e — a parte que quase ninguém faz — confirmar que a correção melhorou de verdade.
O que é o N+1, com números
Considere uma listagem de pedidos que mostra o nome do cliente.
public function index()
{
$pedidos = Pedido::latest()->take(200)->get();
return view('pedidos.index', compact('pedidos'));
}@foreach ($pedidos as $pedido)
<tr>
<td>{{ $pedido->numero }}</td>
<td>{{ $pedido->cliente->nome }}</td>
</tr>
@endforeachO que acontece no banco:
-- 1 query para os pedidos
select * from pedidos order by created_at desc limit 200;
-- e então, uma por linha, 200 vezes:
select * from clientes where id = 1 limit 1;
select * from clientes where id = 2 limit 1;
select * from clientes where id = 3 limit 1;
-- ...
201 queries. Esse é o “N+1”: 1 consulta para a lista, mais N consultas para carregar a relação de cada item.
Em desenvolvimento, com banco local e 20 registros de seed, isso responde em 40 ms e ninguém nota. Em produção, com o banco em outra máquina e 1,5 ms de latência de rede por ida e volta, são 300 ms só de rede — antes de o MySQL executar qualquer coisa.
Por que o Eloquent facilita cair nisso
Não é descuido do programador: é uma consequência do lazy loading. Quando você
acessa $pedido->cliente e a relação não está carregada, o Eloquent vai buscar
no banco naquele instante, de forma transparente.
Essa transparência é ótima para escrever código e péssima para performance,
porque a query fica invisível no ponto de uso. Você olha para a linha
{{ $pedido->cliente->nome }} e vê um acesso a propriedade, não um SELECT.
Pior: o problema costuma nascer longe de onde é sentido. O controller está correto. Quem introduziu o N+1 foi a view — ou um accessor, ou uma policy, ou um API Resource, três camadas abaixo.
Detectar
O jeito definitivo: proibir lazy loading
Esta é a mudança de maior impacto que você pode fazer hoje. O Laravel consegue lançar exceção sempre que uma relação for carregada sob demanda.
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Em produção não lança exceção — apenas registra.
Model::preventLazyLoading(! $this->app->isProduction());
}A partir daí, qualquer N+1 vira um erro estourando na cara em desenvolvimento e na suíte de testes. É a diferença entre descobrir o problema no seu ambiente e descobrir no Grafana às três da manhã.
Se você não quer quebrar produção mas quer visibilidade, registre em log:
Model::preventLazyLoading();
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relacao) {
if (app()->isProduction()) {
logger()->warning('Lazy loading detectado', [
'modelo' => $model::class,
'relacao' => $relacao,
]);
return;
}
throw new \RuntimeException(
sprintf('Lazy loading de [%s] em [%s].', $relacao, $model::class)
);
});O irmão mais rigoroso, Model::shouldBeStrict(), liga três proteções de uma vez:
proíbe lazy loading, proíbe atribuição silenciosa de atributo não preenchível e
proíbe acesso a atributo que não veio no SELECT. Vale a pena em projeto novo;
em projeto legado, ligue uma de cada vez.
Ver as queries de uma requisição específica
Para investigar um endpoint pontual, DB::listen resolve sem instalar nada:
use Illuminate\Support\Facades\DB;
DB::enableQueryLog();
$pedidos = Pedido::latest()->take(200)->get();
$pedidos->each(fn ($p) => $p->cliente->nome);
$queries = DB::getQueryLog();
dump([
'total' => count($queries),
'tempo_ms' => round(collect($queries)->sum('time'), 2),
]);Em teste automatizado, dá para transformar isso em asserção — e aí o N+1 não volta:
it('lista pedidos sem N+1', function () {
Pedido::factory()->count(50)->create();
DB::enableQueryLog();
$this->get('/pedidos')->assertOk();
expect(DB::getQueryLog())->toHaveCount(2);
});Ferramentas
- Laravel Debugbar — mostra a contagem e destaca queries duplicadas. É o caminho mais rápido em desenvolvimento.
- Laravel Telescope — melhor para ambiente compartilhado, porque grava a requisição inteira para você analisar depois.
- Clockwork — alternativa leve ao Debugbar, com um painel decente de banco de dados.
Eliminar
with() — eager loading no momento da consulta
// 201 queries
$pedidos = Pedido::latest()->take(200)->get();
// 2 queries
$pedidos = Pedido::with('cliente')->latest()->take(200)->get();O que o Eloquent faz agora:
select * from pedidos order by created_at desc limit 200;
select * from clientes where id in (1, 2, 3, /* ... */);
Duas queries, independente de serem 200 ou 20.000 pedidos.
load() e loadMissing() — depois do fato
Quando o modelo já veio de outro lugar (um route model binding, por exemplo):
public function show(Pedido $pedido)
{
// Carrega mesmo se já estiver carregado — desperdiça uma query
$pedido->load('itens.produto');
// Só carrega o que ainda falta
$pedido->loadMissing('itens.produto');
return view('pedidos.show', compact('pedido'));
}Prefira loadMissing por padrão. load só quando você quer forçar a releitura.
Contar sem carregar: withCount e família
Este é o erro que mais desperdiça memória. Para mostrar “12 itens”, muita gente carrega os 12 itens.
// Traz todos os itens de todos os pedidos, só para contar
$pedidos = Pedido::with('itens')->get();
// blade: {{ $pedido->itens->count() }}
// Faz a contagem no banco, traz um número por linha
$pedidos = Pedido::withCount('itens')->get();
// blade: {{ $pedido->itens_count }}A mesma família resolve outros agregados sem hidratar objeto nenhum:
$pedidos = Pedido::query()
->withCount('itens')
->withSum('itens', 'valor') // $pedido->itens_sum_valor
->withMax('itens', 'valor') // $pedido->itens_max_valor
->withExists('pagamentos') // $pedido->pagamentos_exists (bool)
->get();withExists merece destaque: para responder “tem pelo menos um?”, ele gera um
EXISTS no SQL, que o banco resolve muito mais rápido que um COUNT completo.
Eager loading aninhado e com condição
Pedido::with([
// Aninhado: pedido -> itens -> produto -> categoria
'itens.produto.categoria',
// Só as colunas necessárias. A chave estrangeira é obrigatória!
'cliente:id,nome,email',
// Com condição e ordenação
'itens' => fn ($query) => $query->where('ativo', true)->orderBy('posicao'),
// Relação de relação com condição
'itens.produto' => fn ($query) => $query->select('id', 'nome', 'sku'),
])->get();A armadilha do cliente:id,nome,email: se você omitir a coluna que liga as
tabelas, o Eloquent não consegue casar os resultados e a relação vem null sem
erro nenhum. Para belongsTo, inclua a chave primária; para hasMany, inclua a
chave estrangeira.
Relações polimórficas: morphWith
with() não resolve morphTo, porque o Eloquent não sabe de antemão qual
modelo vai carregar. O resultado é um N+1 por tipo.
use Illuminate\Database\Eloquent\Relations\MorphTo;
$atividades = Atividade::with([
'assunto' => fn (MorphTo $morphTo) => $morphTo->morphWith([
Pedido::class => ['cliente'],
Comentario::class => ['autor', 'post'],
Fatura::class => ['itens'],
]),
])->get();Sempre carregar: $with no modelo
Se uma relação é necessária em toda leitura, declare no modelo:
class Pedido extends Model
{
protected $with = ['cliente'];
}Use com parcimônia. Isso passa a carregar a relação em toda consulta,
inclusive nas que não precisam dela. Quando não precisar, desligue com
Pedido::without('cliente'). Na minha experiência, $with compensa em pouquíssimos
casos — normalmente é melhor ser explícito em cada consulta.
Quando eager loading piora
Aqui é onde a maioria dos artigos para, e onde os problemas de verdade começam.
with() não é remédio universal.
Carregar 40 colunas para usar 2
Se o cliente tem um campo observacoes do tipo TEXT com 8 KB e você só precisa
do nome, with('cliente') traz os 8 KB por linha. Em 200 linhas são 1,6 MB
atravessando a rede e ocupando memória do PHP.
Pedido::with('cliente:id,nome')->get();O produto cartesiano em hasMany aninhado
Carregar duas relações hasMany no mesmo nível é seguro — o Eloquent faz uma
query separada para cada uma. O problema aparece quando você força tudo numa
query só com join:
// 10 pedidos × 20 itens × 5 pagamentos = 1.000 linhas
// para representar 10 pedidos
Pedido::query()
->join('itens', 'itens.pedido_id', '=', 'pedidos.id')
->join('pagamentos', 'pagamentos.pedido_id', '=', 'pedidos.id')
->get();Deixe o Eloquent fazer queries separadas. Três consultas de 10, 200 e 50 linhas custam menos que uma de 1.000.
Conjuntos grandes: chunk e lazy
Em comando de terminal ou job que processa a base inteira, with() sozinho não
salva — você ainda carrega tudo na memória.
// Estoura a memória com 500 mil pedidos
Pedido::with('itens')->get()->each(fn ($p) => $this->processar($p));
// Processa em blocos de 500, com eager loading dentro de cada bloco
Pedido::with('itens')->chunkById(500, function ($pedidos) {
foreach ($pedidos as $pedido) {
$this->processar($pedido);
}
});
// Mesma coisa com sintaxe de iterador
foreach (Pedido::with('itens')->lazyById(500) as $pedido) {
$this->processar($pedido);
}Use chunkById em vez de chunk sempre que estiver alterando os registros
durante a iteração. chunk usa OFFSET, e se você modifica a condição do
WHERE no meio do caminho, registros são pulados silenciosamente.
O limite do IN (...)
Com dezenas de milhares de IDs, a cláusula WHERE id IN (...) gerada pelo eager
loading vira uma query gigantesca. Alguns bancos têm limite de tamanho de
statement, e o planejador pode desistir do índice. Se você chegou nesse ponto,
chunk já era necessário de qualquer jeito.
Medir de verdade
Contar queries é o primeiro sinal, não a conclusão. Duas queries ruins podem ser piores que vinte boas.
DB::listen(function ($query) {
if ($query->time > 100) {
logger()->warning('Query lenta', [
'sql' => $query->sql,
'tempo_ms' => $query->time,
]);
}
});E no banco, confirme que a query de eager loading usa índice:
EXPLAIN select * from clientes where id in (1, 2, 3, 4, 5);
O que você quer ver na coluna type é range ou eq_ref. Se aparecer ALL,
a consulta está varrendo a tabela inteira — e nesse caso o problema não era o
N+1, era a falta de índice na chave estrangeira. Vale conferir: foreignId()
no Laravel não cria índice sozinho a menos que você adicione ->constrained()
ou ->index().
Checklist
O que eu verifico antes de considerar uma listagem pronta:
Model::preventLazyLoading()está ligado fora de produção- Toda consulta que alimenta uma listagem declara suas relações no
with() - Contadores usam
withCount/withExists, não->count()sobre coleção - Relações com colunas grandes pedem só os campos usados (
cliente:id,nome) morphTousamorphWith- Comandos e jobs que varrem a base usam
chunkByIdoulazyById - Toda chave estrangeira tem índice
- Existe um teste que trava a contagem de queries do endpoint crítico
O item 8 é o que impede a regressão. Sem ele, o N+1 volta no próximo sprint — alguém adiciona um campo na view, e ninguém percebe até a página ficar lenta de novo.