
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/*.phpcomo fonte de configuração;.envapenas 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.







