Laravel Eloquent: O Problema do N+1
O Laravel é um framework poderoso que simplifica significativamente o desenvolvimento de aplicações, oferecendo diversos recursos que automatizam tarefas comuns. No entanto, esse nível de abstração pode acabar escondendo alguns detalhes importantes relacionados à performance, especialmente no uso do Eloquent, seu ORM. Um dos problemas mais comuns que surgem nesse contexto é o N+1 Query Problem, que muitas vezes passa despercebido em ambientes com poucos registros, mas, à medida que o volume de dados cresce, pode gerar gargalos sérios no banco de dados e impactar diretamente a performance da aplicação.
O que é o Problema N+1?
O problema N+1 ocorre quando sua aplicação realiza uma consulta principal no banco de dados e, para cada registro retornado, executa uma nova consulta para buscar dados relacionados. Na prática, isso significa uma query inicial mais N queries adicionais — uma para cada item —, o que resulta em um volume excessivo de consultas. Esse comportamento gera sobrecarga no banco de dados, aumentando o tempo de resposta da aplicação e comprometendo sua performance, especialmente quando lidamos com grandes volumes de dados.
Exemplo prático para entender melhor
Antes de partirmos para o exemplo, vamos instalar o pacote Laravel Debugbar. Com ele, conseguimos visualizar de forma detalhada as queries que estão sendo executadas no banco de dados, além de fornecer diversas outras informações úteis sobre o funcionamento da aplicação.
composer require barryvdh/laravel-debugbar --dev
Esse pacote adiciona uma barra de ferramentas na parte inferior das páginas da sua aplicação. Ela ficará visível sempre que a variável de ambiente APP_DEBUG estiver configurada como true no arquivo .env.
Atenção: nunca deixe a Debugbar habilitada em ambientes de produção. Pois compromete a segurança da aplicação expondo informações sensíveis do sistema. Certifique-se de desativá-la definindo a variável
APP_DEBUG=falseno arquivo.env.

Agora sim, vamos ao exemplo prático. Para isso, começaremos criando alguns models com seus respectivos relacionamentos.
class Post extends Model
{
use HasFactory;
protected $table = 'post';
public $fillable = [
'id',
'titulo',
'conteudo'
];
public function comentarios()
{
return $this->hasMany(Comentario::class);
}
}
class Comentario extends Model
{
use HasFactory;
protected $table = 'comentario';
public $fillable = [
'id',
'post_id',
'usuario_id',
'conteudo'
];
public function post()
{
return $this->belongsTo(Post::class);
}
public function usuario()
{
return $this->belongsTo(Usuario::class);
}
}
class Usuario extends Model
{
use HasFactory;
protected $table = 'usuario';
public $fillable = [
'id',
'nome'
];
}
Agora vamos analisar um exemplo simples. No nosso banco de dados, temos 10 registros de posts. Cada post possui 3 comentários associados, e, para cada comentário, existe um usuário relacionado. No total, isso representa 10 posts, 30 comentários e 30 usuários.
// controller
$posts = Post::all();
// na view - *.blade.php
@foreach ($posts as $post)
<div>
<h2> {{ $post->titulo }} </h2>
<div> {{ $post->conteudo }} </div>
</div>
@endforeach
Vamos analisar as consultas que foram executadas na Debugbar:

Como podemos observar, apenas uma query foi executada. Isso acontece porque carregamos apenas os posts e acessamos exclusivamente os atributos da própria tabela post. Até aqui, tudo certo — ainda não enfrentamos o problema N+1.
Agora, vamos ver o que acontece quando tentamos acessar os dados de um relacionamento dentro de um loop.
// no controller
$posts = Post::all();
// na view - *.blade.php
@foreach ($posts as $post)
<div>
<h2>{{ $post->titulo }}</h2>
<div>{{ $post->conteudo }}</div>
<hr>
@foreach ($post->comentarios as $comentario)
<div> -> {{ $comentario->conteudo }}</div>
@endforeach
</div>
@endforeach
Agora, vamos conferir as queries executadas pelo Debugbar:

Perceba que agora foram executadas 11 queries: a primeira busca todos os posts, e, em seguida, para cada post retornado, é feita uma nova query para buscar seus comentários. Isso resulta em 10 consultas adicionais na tabela comentario, uma para cada post retornado na primeira query. Note que cada uma dessas consultas adicionais contém uma cláusula WHERE filtrando pelo post_id correspondente.
E se tivéssemos 100 posts no banco? Seriam executadas 101 consultas. Com 1000 posts, seriam 1001 consultas, e assim sucessivamente. Dá para perceber facilmente o quanto esse problema pode se tornar crítico à medida que os dados crescem.
Sempre que acessamos o relacionamento $post->comentarios, uma nova consulta é disparada no banco de dados. Um código aparentemente simples e inofensivo acaba gerando um sério problema de performance, justamente porque os dados dos relacionamentos são carregados de forma lazy (sob demanda), ou seja, a consulta só é feita no momento em que o relacionamento é acessado.
Solucionando o problema do N+1
Resolver esse problema é bem simples. Em vez de carregar os relacionamentos de forma lazy (sob demanda), podemos carregá-los de forma eager (antecipada). Para isso, o Laravel oferece o método with(), que permite informar quais relacionamentos devem ser carregados junto com a consulta principal, evitando assim múltiplas queries desnecessárias.
// lazy - suscetível ao problema de N+1
$posts = Post::get();
// eager - trazendo todos os registros de comentario de uma vez
$posts = Post::with('comentarios')->get();
Vamos refazer o exemplo anterior, mas desta vez carregando os comentários de forma antecipada, utilizando eager loading.
// no controller
$posts = Post::with('comentarios')->get();
//na view - *.blade.php
@foreach ($posts as $post)
<div>
<h2>{{ $post->titulo }}</h2>
<div>{{ $post->conteudo }}</div>
<hr>
@foreach ($post->comentarios as $comentario)
<div> -> {{ $comentario->conteudo }}</div>
@endforeach
</div>
@endforeach
Vamos conferir mais uma vez no Debugbar como ficaram as consultas:

Desta vez, foram executadas apenas duas consultas: uma para buscar todos os posts e outra para recuperar todos os comentários relacionados a esses posts. Perceba que, na consulta dos comentários, o Laravel utiliza uma cláusula WHERE IN com os IDs de todos os posts retornados na primeira query.
O método with() aceita um array com todos os relacionamentos a serem carregados de forma eager. E também podemos carregar os relacionamentos em profundidade, como, por exemplo, também trazer os usuários dos comentários do post.
O método with() permite passar um array com todos os relacionamentos que você deseja carregar de forma antecipada (eager loading). Além disso, é possível carregar relacionamentos em níveis mais profundos. Por exemplo, além dos comentários do post, você também pode trazer os usuários associados a cada comentário.
// no controller
$posts = Post::with(['comentarios', 'comentarios.usuario'])->get();
// na view - *.blade.php
@foreach ($posts as $post)
<div>
<h2>{{ $post->titulo }}</h2>
<div>{{ $post->conteudo }}</div>
<hr>
@foreach ($post->comentarios as $comentario)
<div> -> {{ $comentario->conteudo }} - {{ $comentario->usuario->nome }}</div>
@endforeach
</div>
@endforeach
Conferindo no Debugbar, podemos observar como ficaram as consultas.

Foram executadas três consultas: uma para buscar todos os posts, outra para recuperar todos os comentários desses posts e a terceira para obter todos os usuários associados aos comentários.
Outro ponto importante é ter atenção para não confundir o método do relacionamento com o atributo que contém os dados já carregados de forma eager. Usar o método por engano faz com que uma nova consulta ao banco seja disparada. Veja este exemplo:
// no controller
$posts = Post::with(['comentarios','comentarios.usuario'])->get();
/na view - *.blade.php
@foreach ($posts as $post)
<div>
<h2>{{ $post->titulo }}</h2>
<div>{{ $post->conteudo }}</div>
<hr>
<div>Total Comentários: {{ $post->comentarios()->count() }}</div>
@foreach ($post->comentarios as $comentario)
<div> -> {{ $comentario->conteudo }} - {{ $comentario->usuario->nome }}</div>
@endforeach
</div>
@endforeach
Perceba que, na linha 10, utilizamos $post->comentarios()->count() em vez de $post->comentarios->count(). Ao chamar o método do relacionamento em vez do atributo, o Laravel executa uma consulta de agregação COUNT no banco para cada post. Isso gera uma query extra para cada item. Podemos observar esse comportamento no Debugbar.

Se você precisa apenas saber a quantidade de itens de um relacionamento, sem acessar os dados desse relacionamento, pode utilizar o método withCount('<relacionamento>'). Ele adiciona automaticamente ao objeto um novo atributo chamado <relacionamento>_count, contendo esse total. Veja o exemplo:
// no controller
$posts = Post::withCount('comentarios')->get();
// na view - *.blade.php
@foreach ($posts as $post)
<div>
<h2>{{ $post->titulo }}</h2>
<div>{{ $post->conteudo }}</div>
<hr>
<div>Total Comentários: {{ $post->comentarios_count}}</div>
</div>
@endforeach
Vamos conferir o resultado no Debugbar:

Conclusão
O problema N+1 é uma armadilha muito comum no desenvolvimento com Eloquent. Por isso, sempre que formos percorrer uma relação, é fundamental lembrar de carregá-la de forma eager utilizando o método with().
No entanto, é importante ter equilíbrio: não faz sentido carregar dados de relacionamentos que não serão utilizados, pois isso também gera desperdício de recursos e pode impactar a performance.
Além disso, nunca subestime o poder de ferramentas como o Laravel Debugbar. Ela permite visualizar as consultas executadas no banco e pode ajudar a identificar gargalos de performance que, muitas vezes, passam despercebidos em bancos pequenos ou testes superficiais. Por fim, lembre-se sempre de testar sua aplicação com uma quantidade significativa de dados para garantir que ela continue performática à medida que cresce.
T++
