Lista curta e prática das convenções que seguimos em projectos Laravel. Cada uma destas regras já nos poupou pelo menos uma noite de debug.
1. Não chamar env() fora de config/
A função env() só lê do .env quando o cache de config não existe. Em produção corremos php artisan config:cache, e a partir desse momento qualquer env() num controller ou service devolve o default do segundo argumento — silenciosamente.
// ❌ Mau — em produção devolve null se config estiver em cache
$key = env('STRIPE_KEY');
// ✅ Bom — config/services.php lê env() uma vez no boot
// config/services.php
'stripe' => [
'key' => env('STRIPE_KEY'),
],
// no service / controller
$key = config('services.stripe.key');
2. Eager loading para matar o N+1
Iterar $posts e ler $post->author->name lança uma query por post. Com 50 posts, são 51 queries. Carregar a relação à frente reduz para duas.
// ❌ N+1
$posts = Post::all();
foreach ($posts as $post) {
echo $post->author->name; // query por iteração
}
// ✅ 2 queries no total
$posts = Post::with('author')->get();
Em local/dev, activar Model::preventLazyLoading() no AppServiceProvider::boot() — passa a explodir em vez de degradar em silêncio.
3. Validação em Form Requests, não no controller
Mover as regras para classes App\Http\Requests mantém o controller magro e a regra reutilizável (e testável de forma isolada).
// app/Http/Requests/StorePostRequest.php
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'body' => ['required', 'string'],
];
}
// no controller
public function store(StorePostRequest $request)
{
Post::create($request->validated());
}
4. Filas para tudo o que demora
Envio de emails, geração de PDFs, chamadas a APIs externas — nunca no request síncrono. Despacha um job e devolve a resposta imediatamente.
// ❌ bloqueia o request (cliente espera 3-5s)
Mail::to($user)->send(new InvoicePaid($order));
// ✅ resposta imediata, worker trata depois
SendInvoiceEmail::dispatch($order);
Mesmo que o driver seja sync em local, o código já está pronto para escalar quando ligares Redis e um worker.
5. Migrations e seeders, nunca SQL à mão
Toda a alteração de schema vai numa migration. Dados de referência (categorias, roles, países) ficam em seeders. O ambiente local recria-se com um comando:
php artisan migrate:fresh --seed
Se um colega não consegue arrancar o projecto sem instruções extra, faltam migrations ou seeders — não é um problema de documentação.
6. Autorização em Policies, não espalhada
if ($user->id === $post->user_id) repetido em vinte controllers é uma fuga de segurança à espera de acontecer. Centraliza numa Policy:
// app/Policies/PostPolicy.php
public function update(User $user, Post $post): bool
{
return $user->id === $post->user_id;
}
// no controller
$this->authorize('update', $post);
Quando a regra mudar (por exemplo, admins também podem editar), mudas num único sítio.
7. .env.example actualizado, validado em CI
Toda a variável nova no .env entra também no .env.example. Um simples diff em CI evita o clássico “funciona na minha máquina”:
# CI step
diff <(grep -oE '^[A-Z_]+' .env.example | sort -u) \
<(grep -oE '^[A-Z_]+' .env | sort -u)
8. Logs estruturados, não dd() em produção
dd() mata o request. dump() polui o output. Em produção usa contexto estruturado:
Log::info('order.paid', [
'order_id' => $order->id,
'amount_eur' => $order->total,
'gateway' => 'stripe',
]);
Canal daily para rotação automática, ou um stack que envie a Sentry/Logtail. dd() fica para o tinker.
Oito linhas de defesa que custam pouco a aplicar e poupam muitas horas a debugar.