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.

Exemplo da Debugbar do Laravel
Laravel Debugbar exibindo as queries executadas na página

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:

Debugbar demostrando uma consulta sem o problema de N+1

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:

Debugbar demostrando uma consulta com o problema de N+1. Realizando 11 queries.

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:

Debugbar demostrando uma consulta que utiliza o método with, tranzendo os relacionamentos de formar eager.

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.

Debugbar demostrando uma consulta que utiliza o método with, tranzendo os relacionamentos de formar eager em profundidade.

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.

Debugbar demostrando uma consulta chamando o método de relacionamento do model ao invés do atributo com os dados carregados.

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:

Debugbar demostrando uma consulta utilizando o método withCount.

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