Voltar ao blog
LaravelPHParquiteturadesenvolvimentobackend

Laravel em projetos grandes: o que muda quando o sistema cresce

Quem me contratou para auditar um sistema Laravel há alguns anos ficou surpreso com a primeira coisa que pedi: me mostra o tamanho médio dos controllers. Não o número de rotas, não a estrutura do banco, não a cobertura de testes. O tamanho dos controllers.

Porque controller inchado é o primeiro sintoma de tudo que vai dar errado mais tarde.

O Laravel facilita demais o começo. Migrations, Eloquent, autenticação, filas, jobs, tudo ali do jeito certo. Nos primeiros meses do projeto, a produtividade é impressionante. O problema é que essa facilidade tem um lado: o framework não te força a organizar o código de uma forma que escala. Ele te deixa colocar qualquer coisa em qualquer lugar.

E quando você olha pra um projeto grande que não teve arquitetura pensada desde cedo, o quadro é quase sempre o mesmo: lógica de negócio espalhada nos controllers, Eloquent sendo chamado em lugares que não deveriam ter a menor ideia de como os dados são persistidos, validação misturada com processamento, e-mail sendo enviado dentro de uma transaction de banco de dados.

O pior é que funcionou por anos assim. Está em produção, tem cliente usando, ninguém reclama. O custo fica invisível até o dia que você precisa mudar uma regra de negócio e descobre que ela está duplicada em quatro lugares diferentes.

Form Requests resolvem mais do que validação

Esse é um dos recursos mais subestimados do framework. A maioria usa Form Request pra validar campos de formulário. Faz sentido, é o caso principal. Mas quando você começa a centralizar toda a lógica de autorização e preparação de dados ali também, o controller fica limpo de um jeito que surpreende.

Já trabalhei em projeto onde o authorize() do Form Request consultava o banco, verificava permissão baseada em contexto real do negócio, e devolvia 403 com a mensagem correta. O controller nem sabia que aquela verificação existia. Quando a regra de acesso mudou, mudou no Form Request. Um lugar só, testável de forma isolada.

Parece detalhe. Não é.

O elefante na sala: Eloquent em escala

Prefiro PostgreSQL. Sempre preferi. Mas independente do banco, o maior gerador de problema em produção em aplicações Laravel não é a escolha do banco. É o uso descuidado do Eloquent.

N+1 é clássico. Todo mundo que trabalhou com Eloquent já olhou pro Telescope em algum momento e levou um susto com o número de queries numa requisição. A correção é trivial quando você sabe o que está acontecendo. O problema é quando não sabe, porque o sistema parece funcionar, só está fazendo 120 queries em vez de 8.

O que piora em sistemas grandes é que esse tipo de coisa se esconde atrás de múltiplos níveis de indireção. Um método chama outro que acessa uma relação que não estava carregada. Você adicionou uma feature nova que parecia inofensiva e triplicou o número de queries numa rota que já existia há dois anos. Em staging com seed de 200 registros não aparece nada. Em produção com 80 mil começa como timeout intermitente às segundas-feiras, quando tem mais carga.

Já passei uma tarde inteira investigando exatamente esse cenário. O erro não era óbvio. A query lenta aparecia esporadicamente no APM. O problema estava numa relação carregada dentro de um loop dentro de outra relação. Identificar exigiu rastrear a stack completa. Corrigir levou cinco minutos.

Eventos e listeners mudam o jogo

Essa é a mudança de mentalidade que mais transforma a manutenabilidade de um projeto Laravel que cresceu.

Quando o usuário completa o cadastro, o que acontece? E-mail de boas-vindas. Notificação pro time comercial. Registro no CRM. Criação do plano de trial. Em muitos projetos, essas ações estão numa função enorme chamada register(), ou distribuídas em métodos privados do mesmo controller.

Com eventos, o UserRegistered é disparado quando o usuário é criado. Cada listener cuida de uma responsabilidade. Adicionar uma quinta ação significa criar um listener novo, sem tocar no fluxo que já existe e já funciona em produção. Remover o CRM de certos planos significa ajustar um listener, não varrer código procurando onde aquela chamada está.

O código não fica mais inteligente. Fica mais separado. E separado é o que escala.

Aliás, listeners que podem demorar vão pra fila. Sempre. E-mail nunca deveria acontecer no ciclo síncrono de uma requisição web. Chamada de API externa, idem. Isso é básico, mas é surpreendente quantos sistemas em produção ainda travam a resposta do usuário esperando um SMTP responder.

Service layer: vale a pena ou é burocracia?

Opinião polêmica: depende do projeto.

Em sistemas simples, camada de serviço é burocracia pura. Você cria um UserService com um método createUser que chama o User::create e não faz nada além disso. É indireção sem benefício real. Gera arquivo, gera namespace, gera confusão pra quem entra no projeto depois.

Em sistemas com regras de negócio reais, a história muda. Quando criar um usuário significa verificar disponibilidade do e-mail, provisionar uma conta em serviço externo, criar vínculos em várias tabelas e disparar notificações condicionadas ao tipo de plano, esse processo precisa de um lugar pra morar que não seja o controller. Um lugar que possa ser testado isoladamente, chamado de um Artisan command, invocado por um job de importação em massa sem duplicar nada.

O erro que vejo com mais frequência é criar a camada prematuramente, com uma classe por entidade do sistema, mesmo quando a maioria desses serviços não tem lógica real. Aí o projeto vira três camadas de burocracia pra salvar um formulário de contato.

Colocar estrutura só quando o código está pedindo por ela não é descuido. É julgamento.

Uma coisa que a maioria ignora: Artisan commands

Pra muita equipe, Artisan command é aquele cara que serve pra gerar arquivo. php artisan make:controller. Pronto.

Na prática, command é uma das formas mais limpas de expor lógica de negócio de formas alternativas. Importação de dados via CSV? Command. Recalcular um campo desnormalizado depois de uma migração de dados? Command. Rodar uma lógica manualmente durante um incidente em produção sem precisar de interface? Command.

O que eu gosto é que um command bem escrito é, essencialmente, uma forma de documentar como certas operações críticas funcionam. Fica no histórico do git, tem log decente, pode ser chamado via cron. É infraestrutura de manutenção que muita equipe constrói depois do desastre, quando deveria ter construído antes.

Testes são onde você descobre se o design está bom

Quando um pedaço de código é difícil de testar, geralmente é porque está acoplado demais, depende de muita coisa externa ou está fazendo mais de uma coisa ao mesmo tempo.

Em projetos grandes no Laravel, o ponto que mais revela isso é tentar testar um método de controller que tem lógica de negócio. Você descobre que precisa mocar o banco, o mailer, um facade, dois serviços externos. O setup do teste fica maior que o próprio teste. E aí você tem duas escolhas: não testar, ou extrair aquela lógica pra um lugar que dá pra testar de forma limpa.

A segunda opção sempre foi a certa.

Não tenho cobertura de testes obsessiva em todos os projetos que mantenho. Mas as partes que mais doeram quando falharam em produção são, quase sempre, as partes que não tinham nenhum teste. Pois é. Não é coincidência e não é azar.

O framework te dá as ferramentas. O que ele não te dá é a disciplina de usá-las no momento certo.


Gabriel Schunck está disponível para consultorias em projetos PHP e Laravel. Se o seu sistema cresceu além do ponto onde é fácil mexer, entra em contato pelo gabriels.dev.br.

Falar no WhatsApp