Boas práticas para estruturar projetos em Laravel de médio e grande porte

Boas práticas para estruturar projetos em Laravel de médio e grande porte

Quando o projeto Laravel é pequeno, quase qualquer organização funciona. Você cria controllers, models, requests, alguns services, resolve tudo no “app/” e segue. O problema começa quando o sistema cresce: mais módulos, mais regras, integrações, filas, diferentes times mexendo no mesmo lugar… e, de repente, o que era simples vira uma base difícil de evoluir.

Estruturar um projeto Laravel bem não é sobre “inventar arquitetura”. É sobre reduzir atrito: facilitar manutenção, evitar acoplamento desnecessário e deixar o código previsível para quem chega depois.

A seguir estão práticas que funcionam bem em projetos médios e grandes, especialmente quando já existe operação de produção, roadmap e mudanças frequentes.


Comece pela regra: controller não é lugar de lógica

Controller deveria orquestrar: receber request, validar, chamar a camada certa e responder. Quando regra de negócio fica no controller, você cria um ponto de acoplamento difícil de testar e difícil de reutilizar. Em projeto grande isso vira padrão ruim rapidamente, porque todo mundo copia o que já existe.

O que funciona melhor é mover lógica para classes dedicadas (services/use cases/actions), e deixar o controller como uma “casca” fina. Você ganha clareza, reaproveitamento e testes mais simples.


Estruture por domínio (feature) quando o sistema crescer

A estrutura padrão do Laravel (Controllers, Models, Jobs, etc.) é ótima até um certo ponto. Em sistemas grandes, ela tende a espalhar a mesma funcionalidade por várias pastas e o dev precisa “caçar” arquivos para entender um fluxo.

Uma abordagem que escala melhor é organizar por domínio/feature. Exemplo: tudo relacionado a “Billing” fica próximo (requests, actions, policies, resources, etc.). Isso reduz a fricção para manter e evoluir partes específicas do sistema.

Você não precisa mudar tudo de uma vez. Dá para começar com um ou dois domínios e ir migrando aos poucos, sem reescrever o projeto inteiro.

Estrutura tradicional do Laravel (por tipo)

app/
├── Http/
│ ├── Controllers/
│ ├── Requests/
│ └── Resources/
├── Models/
├── Jobs/
├── Policies/
├── Services/

Problema em projeto grande:
para entender Billing, você precisa abrir 6 pastas diferentes.

Estrutura por domínio / feature

app/
└── Domains/
└── Billing/
├── Http/
│ ├── Controllers/
│ ├── Requests/
│ └── Resources/
├── Actions/
├── Jobs/
├── Policies/
├── Models/
└── Services/

Ou até mais simples:

app/
└── Billing/
├── BillingController.php
├── CreateInvoice.php
├── Invoice.php
├── InvoicePolicy.php
├── SendInvoiceJob.php
└── InvoiceResource.php

O Laravel não se importa com a pasta, desde que o namespace esteja correto.

Por que isso escala melhor?

Porque quando alguém entra no projeto e precisa mexer em Billing:

  • não precisa caçar arquivos espalhados
  • não quebra coisas de outros domínios
  • entende o fluxo mais rápido
  • reduz medo de alterar código

Em projeto grande, isso faz muita diferença.


Requests e validações como primeiro filtro

Em projeto grande, dado ruim entrando vira bug caro. Centralizar validação em Form Requests mantém o controller limpo e torna regras de entrada explícitas.

Além disso, vale padronizar:

  • mensagens de erro;
  • formatos de resposta (principalmente APIs);
  • validações compartilhadas (traits, rules customizadas).

Quando a validação é consistente, o sistema vira mais previsível e o time para de reinventar a roda a cada endpoint.


Padronize respostas de API desde cedo

Quando cada endpoint responde de um jeito, cada client precisa de lógica diferente, e isso vira dívida técnica distribuída. Para projetos médios e grandes, uma camada consistente de resposta é obrigatória: Resources, transformers, ou uma padronização interna.

O ponto aqui não é “estética”. É operação. Quando algo quebra, você quer logs previsíveis, payload previsível, erros previsíveis. Isso reduz tempo de diagnóstico.


Regras de negócio: não misture persistência com decisão

Um erro comum em Laravel é “decidir” coisas dentro de models e ao mesmo tempo persistir em todo lugar, sem fronteira clara. Em sistemas maiores, o ideal é separar:

  • Decisão / regra (o que pode ou não pode)
  • Persistência (salvar, consultar)
  • Orquestração (fluxo do caso de uso)

Isso permite evoluir regra sem quebrar camada de persistência, e vice-versa. E facilita testar o que realmente importa: a regra.


Jobs, filas e idempotência não são detalhe

Em projeto grande, você inevitavelmente vai para filas: e-mails, notificações, integrações, processamento pesado. O que quebra sistemas grandes não é “usar fila”. É usar fila sem cuidado com:

  • idempotência (job rodar duas vezes sem duplicar efeito);
  • retries (quando deve tentar de novo e quando deve falhar);
  • timeouts;
  • dead letter / estratégia para falhas recorrentes;
  • logs por job.

Se o seu projeto depende de integração externa, trate isso como cenário normal: o externo vai falhar. A fila é o lugar certo para absorver isso, desde que bem desenhado.


Serviços externos: isole e trate como dependência instável

Integrações externas deveriam estar em classes próprias (clients/adapters), com:

  • timeout explícito;
  • tratamento de erro consistente;
  • retry controlado quando fizer sentido;
  • logs contextualizados.

Evite espalhar Http::post() por todo lugar. Em projeto grande isso vira caos, porque não existe ponto único para ajustar comportamento (ex: mudar header, adicionar auth, alterar timeout).


Migrations, seeds e dados de referência bem cuidados

Projeto grande normalmente tem muita migration. E migration desorganizada vira armadilha: ambientes quebram, dev novo não sobe projeto, staging fica inconsistente.

Boas práticas que ajudam:

  • migrations com nomes claros;
  • dados de referência (enums/tabelas fixas) com seeders idempotentes;
  • evitar “seed que depende de estado manual”;
  • sempre testar “do zero” em ambiente limpo.

Isso evita que o setup do projeto vire uma gincana.


Configuração: tudo em config e .env, nada hardcoded

Quando cresce, o sistema precisa rodar em múltiplos ambientes e, muitas vezes, múltiplos clientes. Configuração hardcoded vira problema de deploy e vira risco de segurança.

Padronize:

  • config/*.php como fonte de configuração;
  • .env apenas para valores variáveis;
  • nunca commitar senhas e keys;
  • e preferencialmente separar integrações por “config + service container”.

Observabilidade: logs estruturados desde o começo

Em projetos grandes, o custo de “descobrir o que aconteceu” é alto. Sem logs bons você perde tempo, perde confiança e perde previsibilidade operacional.

O que vale padronizar:

  • logs com contexto (tenant, user_id, request_id, correlation_id);
  • níveis corretos (info, warning, error);
  • logs de integrações e jobs;
  • e um formato consistente (para facilitar busca).

Isso é o que transforma incidentes em diagnóstico rápido.


Testes: foque no que dá retorno real

Não dá para testar tudo em projeto grande, e tentar fazê-lo costuma falhar. O que dá mais retorno é:

  • testes de regras de negócio (unit/integration);
  • testes de fluxos críticos;
  • testes de contratos de API;
  • e testes de integração com serviços externos (com mocks ou ambientes controlados).

Teste não é para “subir porcentagem”. É para reduzir risco onde mais dói.


Conclusão

Projetos Laravel grandes não quebram porque Laravel é limitado. Eles quebram porque a base cresce sem estrutura, sem padrões e sem fronteiras claras entre responsabilidades.

Se você mantiver controllers finos, separar domínio/feature, isolar integrações, tratar filas com seriedade e padronizar validação/retorno/log, o projeto continua evoluindo sem virar um monstro. E o melhor: o time consegue manter velocidade sem sacrificar estabilidade.