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.