Introdução ao Laravel

Introdução ao Laravel

Instalação, estrutura de diretórios, rotas, controllers, Form Requests e o container de serviços: o caminho até a primeira tela respondendo. Inclui a ordem em que o Laravel escolhe a rota e o motivo de dois Route::resource com o mesmo nome deixarem metade das URLs inalcançáveis.
PHP

• • 31 min de leitura

Laravel é o framework PHP mais popular do mundo, e por boas razões. Ele não apenas resolve problemas técnicos — ele define uma forma de pensar sobre o desenvolvimento web. Com Laravel, você tem um roteador expressivo, ORM elegante, sistema de templates, autenticação, filas, eventos, testes e dezenas de outros recursos integrados e funcionando em harmonia desde o primeiro composer create-project.

Mas Laravel não é magia. Cada recurso que ele oferece é construído sobre os fundamentos que você já aprendeu: namespaces, PSR-4, injeção de dependência, interfaces, padrões de projeto. Entender isso transforma você de um usuário do framework em alguém que realmente domina o que está usando.

Instalação e estrutura

# Criando um novo projeto Laravel
composer create-project laravel/laravel minha-loja

cd minha-loja

# Iniciando o servidor de desenvolvimento
php artisan serve
# http://localhost:8000

# Ou com o Laravel Herd (recomendado para desenvolvimento local)
# https://herd.laravel.com/

Estrutura de diretórios:

minha-loja/
├── app/
│   ├── Http/
│   │   ├── Controllers/     ← Controladores
│   │   └── Middleware/      ← Middlewares HTTP
│   ├── Models/              ← Models Eloquent
│   ├── Providers/           ← Service Providers
│   └── Services/            ← Lógica de negócio (você cria)
├── bootstrap/               ← Bootstrap da aplicação
├── config/                  ← Configurações (database, mail, etc.)
├── database/
│   ├── factories/           ← Factories para testes
│   ├── migrations/          ← Migrations de banco de dados
│   └── seeders/             ← Seeds de dados iniciais
├── public/                  ← Document root (index.php)
├── resources/
│   └── views/               ← Templates Blade
├── routes/
│   ├── web.php              ← Rotas web (com sessão, CSRF)
│   └── api.php              ← Rotas API (stateless)
├── storage/                 ← Logs, cache, uploads
├── tests/                   ← Testes automatizados
├── .env                     ← Variáveis de ambiente
└── artisan                  ← CLI do Laravel

Configuração — o arquivo .env

# .env
APP_NAME="Minha Loja"
APP_ENV=local
APP_KEY=base64:...        # Gerado automaticamente — nunca commite
APP_DEBUG=true
APP_URL=http://localhost:8000

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=minha_loja
DB_USERNAME=root
DB_PASSWORD=

MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025

O arquivo .env nunca vai para o git (está no .gitignore). Em vez disso, commite o .env.example com as chaves mas sem os valores sensíveis. Para gerar a APP_KEY:

php artisan key:generate

Roteamento

<?php
// routes/web.php

use App\Http\Controllers\ProdutoController;
use App\Http\Controllers\CarrinhoController;
use Illuminate\Support\Facades\Route;

// Rota simples com closure
Route::get('/', function () {
    return view('home');
});

// Rota com parâmetro.
// O arquivo de rotas é lido de cima para baixo e vale a PRIMEIRA que
// casar — por isso o exemplo não usa `/produtos/{id}`: ele engoliria o
// `show` do controller registrado mais abaixo, que nunca seria chamado.
Route::get('/busca/{termo}', function (string $termo) {
    return "Buscando por: {$termo}";
});

// Rota com parâmetro opcional
Route::get('/blog/{slug?}', function (?string $slug = null) {
    return $slug ? "Post: {$slug}" : "Lista do blog";
});

// Rota apontando para controller
Route::get('/produtos', [ProdutoController::class, 'index']);
Route::get('/produtos/{produto}', [ProdutoController::class, 'show']);
Route::post('/produtos', [ProdutoController::class, 'store']);
Route::put('/produtos/{produto}', [ProdutoController::class, 'update']);
Route::delete('/produtos/{produto}', [ProdutoController::class, 'destroy']);

// Resource route — cria as 7 rotas CRUD de uma vez.
// Escolha UMA das duas formas: registrar as rotas na mão E o resource
// para o mesmo recurso faz o último sobrescrever silenciosamente os
// anteriores de mesmo método e mesma URI.
Route::resource('produtos', ProdutoController::class);

// Rotas agrupadas com prefixo e middleware.
// `names()` não é enfeite: sem ele este resource repetiria os nomes
// `produtos.index`, `produtos.show`… já usados acima, e o `route()`
// devolve sempre o PRIMEIRO que registrou o nome. As rotas do admin
// ficariam inalcançáveis por nome, sem nenhum aviso.
Route::middleware('auth')->prefix('admin')->group(function () {
    Route::resource('produtos', ProdutoController::class)->names('admin.produtos');
    Route::resource('pedidos', PedidoController::class)->names('admin.pedidos');
});

// Rotas nomeadas — facilita geração de URLs
Route::get('/contato', [ContatoController::class, 'index'])->name('contato');
// Uso: route('contato') → /contato

Controllers

# Criar um controller com os métodos CRUD já estruturados
php artisan make:controller ProdutoController --resource
<?php
declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Produto;
use App\Http\Requests\ProdutoRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class ProdutoController extends Controller
{
    // GET /produtos
    public function index(): View
    {
        $produtos = Produto::where('ativo', true)
            ->orderBy('nome')
            ->paginate(15);

        return view('produtos.index', compact('produtos'));
    }

    // GET /produtos/{produto}
    // O Laravel resolve automaticamente o Model pelo ID (Route Model Binding)
    public function show(Produto $produto): View
    {
        return view('produtos.show', compact('produto'));
    }

    // GET /produtos/create
    public function create(): View
    {
        return view('produtos.create');
    }

    // POST /produtos
    public function store(ProdutoRequest $request): RedirectResponse
    {
        // $request->validated() retorna apenas os campos que passaram na validação
        $produto = Produto::create($request->validated());

        return redirect()
            ->route('produtos.show', $produto)
            ->with('sucesso', 'Produto criado com sucesso!');
    }

    // PUT /produtos/{produto}
    public function update(ProdutoRequest $request, Produto $produto): RedirectResponse
    {
        $produto->update($request->validated());

        return redirect()
            ->route('produtos.show', $produto)
            ->with('sucesso', 'Produto atualizado.');
    }

    // DELETE /produtos/{produto}
    public function destroy(Produto $produto): RedirectResponse
    {
        $produto->delete();

        return redirect()
            ->route('produtos.index')
            ->with('sucesso', 'Produto removido.');
    }
}

Form Requests — validação encapsulada

php artisan make:request ProdutoRequest
<?php
declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ProdutoRequest extends FormRequest
{
    // Autorização — quem pode fazer esta requisição?
    public function authorize(): bool
    {
        return $this->user()?->isAdmin() ?? false;
    }

    // Regras de validação
    public function rules(): array
    {
        return [
            'nome'     => ['required', 'string', 'min:3', 'max:200'],
            'preco'    => ['required', 'numeric', 'min:0'],
            'estoque'  => ['required', 'integer', 'min:0'],
            'ativo'    => ['boolean'],
            'descricao'=> ['nullable', 'string', 'max:2000'],
            'imagem'   => ['nullable', 'image', 'max:2048'], // máx 2MB
        ];
    }

    // Mensagens customizadas
    public function messages(): array
    {
        return [
            'nome.required'  => 'O nome do produto é obrigatório.',
            'preco.min'      => 'O preço não pode ser negativo.',
            'imagem.image'   => 'O arquivo deve ser uma imagem.',
            'imagem.max'     => 'A imagem deve ter no máximo 2MB.',
        ];
    }
}

Service Container e Service Providers

O Service Container do Laravel é um container de DI poderoso. Você registra como criar objetos, e o Laravel injeta automaticamente onde forem necessários:

<?php
// app/Providers/AppServiceProvider.php

namespace App\Providers;

use App\Repositories\ProdutoRepositorioInterface;
use App\Repositories\EloquentProdutoRepositorio;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Bind interface → implementação concreta
        $this->app->bind(
            ProdutoRepositorioInterface::class,
            EloquentProdutoRepositorio::class,
        );
    }

    public function boot(): void
    {
        // Executado após todos os providers registrarem seus serviços
    }
}

// Agora o controller pode declarar a interface no construtor:
class ProdutoController extends Controller
{
    public function __construct(
        private readonly ProdutoRepositorioInterface $repositorio,
    ) {}
}
// O Laravel injeta automaticamente a implementação concreta

Artisan — a CLI do Laravel

# Listar todos os comandos disponíveis
php artisan list

# Criar arquivos
php artisan make:model Produto -mfc      # Model + migration + factory + controller
php artisan make:migration criar_tabela_pedidos
php artisan make:seeder ProdutoSeeder
php artisan make:job ProcessarPagamento
php artisan make:event PedidoCriado
php artisan make:listener EnviarEmailDePedido
php artisan make:command ImportarProdutos

# Executar
php artisan migrate                      # Roda as migrations pendentes
php artisan migrate:fresh --seed         # Recria o banco e roda seeds
php artisan db:seed                      # Apenas seeds
php artisan queue:work                   # Processa filas
php artisan schedule:run                 # Executa tarefas agendadas

# Informações
php artisan route:list                   # Lista todas as rotas
php artisan config:show database         # Exibe config do banco
php artisan tinker                       # REPL interativo — testado no console

O que vale levar da primeira semana de Laravel não é a lista de recursos, e sim a percepção de que nenhum deles é novidade. O roteador é um mapa de padrões para callables; o Form Request é validação movida para uma classe própria; o Service Container é a injeção de dependência que você já fazia na mão, agora com resolução automática por type hint. O framework não substituiu os fundamentos — ele escolheu uma convenção para cada um e passou a cobrar consistência. Por isso quem chega com namespaces, PSR-4 e interfaces na bagagem aprende Laravel em dias, e quem chega sem eles decora comandos do Artisan sem saber o que está acontecendo.

A armadilha desta etapa é a inversa da que se espera: não é a curva de aprendizado, é a facilidade. Registrar rota em três formatos diferentes no mesmo arquivo funciona, e continua funcionando até o dia em que uma delas some silenciosamente atrás de outra. Vale adotar cedo o hábito de conferir o resultado em vez do código: php artisan route:list depois de mexer em rotas, php artisan config:show depois de mexer em configuração, php artisan tinker antes de escrever a query no controller. São três comandos que mostram o que o framework entendeu, e não o que você quis dizer.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Crie um projeto Laravel do zero. Configure o banco de dados. Crie um CRUD completo de Categoria com: migration, model, controller resource, form request com validação, e views Blade básicas.

Ver resposta

✓ Resposta: Duas linhas fazem mais trabalho do que parecem. make:model Categoria -mcr --requests cria migration, controller resource e form requests de uma vez; e Route::resource declara as sete rotas. O ponto de atenção fica no authorize() do FormRequest: deixar return true sem pensar é o furo mais comum em projeto Laravel — a validação passa, a autorização nunca acontece, e ninguém percebe porque a tela funciona.

<?php

// ============ no terminal ============
// composer create-project laravel/laravel loja && cd loja
//
// .env:
//   DB_CONNECTION=mysql
//   DB_DATABASE=loja
//   DB_USERNAME=root
//   DB_PASSWORD=troque-aqui
//
// php artisan make:model Categoria -mcr --requests
//   -m  migration    -c  controller    -r  resource    --requests  form requests
//
// Uma linha cria os cinco arquivos. Vale decorar: o `-mcr` é a diferença
// entre começar o CRUD em 10 segundos e em 10 minutos de `make:` avulsos.

// ------------------- database/migrations/2026_03_01_000000_create_categorias_table.php
declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
    public function up(): void
    {
        Schema::create('categorias', function (Blueprint $tabela): void {
            $tabela->id();
            $tabela->string('nome', 100);
            // unique já cria índice: é por ele que a rota resolve a categoria.
            $tabela->string('slug', 120)->unique();
            $tabela->text('descricao')->nullable();
            $tabela->boolean('ativa')->default(true);
            $tabela->unsignedInteger('ordem')->default(0);
            $tabela->timestamps();
            $tabela->softDeletes();

            // Índice composto na ordem em que a listagem consulta:
            // WHERE ativa = 1 ORDER BY ordem. Índice em coluna solta não
            // serviria para as duas coisas.
            $tabela->index(['ativa', 'ordem']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('categorias');
    }
};

// ---------------------------------------- app/Models/Categoria.php
declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;

class Categoria extends Model
{
    use HasFactory;
    use SoftDeletes;

    protected $table = 'categorias';

    // $fillable, não $guarded = []: a lista branca é o que impede um POST
    // com "ativa=1" de ativar sozinho um registro que a interface não
    // deveria permitir mexer.
    protected $fillable = ['nome', 'slug', 'descricao', 'ativa', 'ordem'];

    protected function casts(): array
    {
        return ['ativa' => 'boolean', 'ordem' => 'integer'];
    }

    protected static function booted(): void
    {
        // Slug derivado do nome quando ninguém informou. Vive no model
        // porque é regra do dado, não do formulário — importação por CSV
        // e seeder ganham o mesmo comportamento de graça.
        static::saving(function (self $categoria): void {
            if (blank($categoria->slug)) {
                $categoria->slug = Str::slug($categoria->nome);
            }
        });
    }

    /** A rota /categorias/{categoria} passa a resolver por slug, não por id. */
    public function getRouteKeyName(): string
    {
        return 'slug';
    }

    public function produtos(): HasMany
    {
        return $this->hasMany(Produto::class);
    }

    /** Escopo local: Categoria::ativas()->get() */
    public function scopeAtivas(Builder $consulta): Builder
    {
        return $consulta->where('ativa', true)->orderBy('ordem')->orderBy('nome');
    }
}

// ---------------------------------------- app/Http/Requests/CategoriaRequest.php
declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class CategoriaRequest extends FormRequest
{
    public function authorize(): bool
    {
        // Deixar `true` aqui sem pensar é o furo silencioso mais comum em
        // projeto Laravel: a validação passa e a autorização nunca acontece.
        return $this->user()?->can('gerenciar-categorias') ?? false;
    }

    /** @return array<string, list<mixed>> */
    public function rules(): array
    {
        $categoria = $this->route('categoria');

        return [
            'nome' => ['required', 'string', 'min:3', 'max:100'],
            'slug' => [
                'nullable', 'string', 'max:120', 'alpha_dash',
                // Na edição, o próprio registro tem de ser ignorado — sem
                // isso, salvar sem mudar o slug acusa "slug já existe".
                Rule::unique('categorias', 'slug')->ignore($categoria?->id),
            ],
            'descricao' => ['nullable', 'string', 'max:2000'],
            'ativa'     => ['boolean'],
            'ordem'     => ['integer', 'min:0', 'max:9999'],
        ];
    }

    /** @return array<string, string> */
    public function messages(): array
    {
        return [
            'nome.required' => 'O nome da categoria é obrigatório.',
            'nome.min'      => 'O nome precisa de ao menos :min caracteres.',
            'slug.unique'   => 'Já existe uma categoria com este endereço.',
        ];
    }

    protected function prepareForValidation(): void
    {
        // Normaliza ANTES de validar: o checkbox não marcado não chega no
        // request, e sem este merge o `boolean` nunca vê o campo.
        $this->merge([
            'nome'  => trim((string) $this->input('nome')),
            'ativa' => $this->boolean('ativa'),
        ]);
    }
}

// ---------------------------------------- app/Http/Controllers/CategoriaController.php
declare(strict_types=1);

namespace App\Http\Controllers;

use App\Http\Requests\CategoriaRequest;
use App\Models\Categoria;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class CategoriaController extends Controller
{
    public function index(): View
    {
        return view('categorias.index', [
            // withCount em vez de contar dentro do Blade: uma consulta com
            // subselect, em vez de uma por linha da tabela.
            'categorias' => Categoria::query()
                ->withCount('produtos')
                ->orderBy('ordem')
                ->paginate(15),
        ]);
    }

    public function create(): View
    {
        return view('categorias.form', ['categoria' => new Categoria()]);
    }

    public function store(CategoriaRequest $request): RedirectResponse
    {
        // O request já chegou validado e autorizado — se algo estivesse
        // errado, o controller nem teria sido chamado.
        $categoria = Categoria::create($request->validated());

        return redirect()
            ->route('categorias.show', $categoria)
            ->with('sucesso', "Categoria \"{$categoria->nome}\" criada.");
    }

    public function show(Categoria $categoria): View
    {
        return view('categorias.show', [
            'categoria' => $categoria->load('produtos'),
        ]);
    }

    public function edit(Categoria $categoria): View
    {
        return view('categorias.form', ['categoria' => $categoria]);
    }

    public function update(CategoriaRequest $request, Categoria $categoria): RedirectResponse
    {
        $categoria->update($request->validated());

        return redirect()
            ->route('categorias.show', $categoria)
            ->with('sucesso', 'Categoria atualizada.');
    }

    public function destroy(Categoria $categoria): RedirectResponse
    {
        // Regra de negócio explícita: apagar categoria não pode arrastar os
        // produtos junto. Melhor recusar e obrigar a mover.
        if ($categoria->produtos()->exists()) {
            return back()->with('erro', 'Mova os produtos antes de excluir a categoria.');
        }

        $categoria->delete();   // soft delete

        return redirect()
            ->route('categorias.index')
            ->with('sucesso', 'Categoria excluída.');
    }
}

// ---------------------------------------- routes/web.php
declare(strict_types=1);

use App\Http\Controllers\CategoriaController;
use Illuminate\Support\Facades\Route;

// Uma linha, sete rotas nomeadas: index, create, store, show, edit,
// update, destroy. `php artisan route:list` mostra todas.
Route::resource('categorias', CategoriaController::class)
    ->middleware(['auth']);

// ---------------------------------------- resources/views/categorias/index.blade.php
@extends('layouts.app')

@section('titulo', 'Categorias')

@section('conteudo')
    <div class="cabecalho">
        <h1>Categorias</h1>
        <a href="{{ route('categorias.create') }}" class="botao">Nova categoria</a>
    </div>

    <table>
        <thead>
            <tr><th>Nome</th><th>Produtos</th><th>Ordem</th><th>Status</th><th></th></tr>
        </thead>
        <tbody>
            {{-- forelse trata a lista vazia sem um @if em volta --}}
            @forelse ($categorias as $categoria)
                <tr>
                    {{-- {{ }} escapa por padrão. Só use {!! !!} quando o HTML
                         for seu; com texto do usuário, é XSS na hora. --}}
                    <td><a href="{{ route('categorias.show', $categoria) }}">{{ $categoria->nome }}</a></td>
                    <td>{{ $categoria->produtos_count }}</td>
                    <td>{{ $categoria->ordem }}</td>
                    <td>{{ $categoria->ativa ? 'Ativa' : 'Inativa' }}</td>
                    <td>
                        <a href="{{ route('categorias.edit', $categoria) }}">Editar</a>

                        <form method="post" action="{{ route('categorias.destroy', $categoria) }}"
                              onsubmit="return confirm('Excluir esta categoria?')">
                            @csrf
                            {{-- HTML só conhece GET e POST: o @method injeta
                                 o campo _method que o Laravel lê. --}}
                            @method('DELETE')
                            <button type="submit">Excluir</button>
                        </form>
                    </td>
                </tr>
            @empty
                <tr><td colspan="5">Nenhuma categoria cadastrada.</td></tr>
            @endforelse
        </tbody>
    </table>

    {{ $categorias->links() }}
@endsection

Exercício 2

Adicione Route Model Binding ao CRUD: ao acessar /categorias/{categoria}, o Laravel deve resolver automaticamente o model pelo ID e retornar 404 se não encontrar.

Ver resposta

✓ Resposta: O scopeBindings() é o detalhe que quase todo mundo descobre tarde: sem ele, /categorias/livros/produtos/9 devolve 200 mesmo que o produto 9 pertença a outra categoria — os dois bindings são resolvidos isoladamente, e a URL passa a afirmar uma relação que não existe. Com ele, o segundo model é buscado dentro do relacionamento do primeiro. O teste test_produto_de_outra_categoria_nao_vaza existe exatamente para travar esse comportamento.

<?php

// ---------------------------------------- routes/web.php
declare(strict_types=1);

use App\Models\Categoria;
use App\Models\Produto;
use Illuminate\Support\Facades\Route;

// 1) BINDING IMPLÍCITO
// O nome do parâmetro {categoria} bate com o nome da variável tipada
// Categoria $categoria — e o Laravel resolve sozinho. Não achou? 404
// automático, antes de o controller rodar.
Route::get('/categorias/{categoria}', function (Categoria $categoria) {
    return view('categorias.show', compact('categoria'));
});

// 2) POR OUTRA COLUNA
// Com getRouteKeyName() => 'slug' no model, isto já resolve por slug. Para
// mudar só nesta rota, declare a coluna na própria URL:
Route::get('/c/{categoria:slug}', function (Categoria $categoria) {
    return $categoria->nome;
});

// 3) ESCOPADO — o detalhe que separa URL bonita de URL correta
// Sem scopeBindings(), /categorias/livros/produtos/9 devolve o produto 9
// mesmo que ele pertença a OUTRA categoria: os dois bindings são resolvidos
// isoladamente. Com ele, o produto é buscado DENTRO da categoria.
Route::get('/categorias/{categoria}/produtos/{produto}', function (
    Categoria $categoria,
    Produto $produto,
) {
    return view('produtos.show', compact('categoria', 'produto'));
})->scopeBindings();

// 4) 404 COM DESTINO
// missing() troca o 404 seco por um redirecionamento explicado — útil
// quando o registro sumiu mas a página ainda é linkada por aí.
Route::get('/categorias/{categoria}', function (Categoria $categoria) {
    return view('categorias.show', compact('categoria'));
})->missing(function () {
    return redirect()
        ->route('categorias.index')
        ->with('erro', 'Esta categoria não existe mais.');
});

// 5) SOFT DELETE
// Por padrão o binding IGNORA registros excluídos. withTrashed() os traz de
// volta — necessário na tela de restauração.
Route::get('/admin/categorias/{categoria}/restaurar', function (Categoria $categoria) {
    $categoria->restore();

    return back()->with('sucesso', 'Categoria restaurada.');
})->withTrashed();

// ---------------------------------------- app/Providers/AppServiceProvider.php
declare(strict_types=1);

namespace App\Providers;

use App\Models\Categoria;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // 6) BINDING EXPLÍCITO — quando a regra não cabe numa coluna.
        // Aqui: aceita id OU slug na mesma rota, e só devolve categoria
        // ativa para quem não é administrador.
        Route::bind('categoria', function (string $valor) {
            $consulta = Categoria::query()
                ->where(is_numeric($valor) ? 'id' : 'slug', $valor);

            if (!request()->user()?->isAdmin()) {
                $consulta->where('ativa', true);
            }

            // firstOrFail lança ModelNotFoundException, que o handler do
            // Laravel converte em 404 — mesmo comportamento do implícito.
            return $consulta->firstOrFail();
        });
    }
}

// ---------------------------------------- app/Http/Controllers/ProdutoController.php
declare(strict_types=1);

namespace App\Http\Controllers;

use App\Models\Categoria;
use App\Models\Produto;
use Illuminate\View\View;

class ProdutoController extends Controller
{
    /**
     * Os dois models chegam prontos. O controller não faz find(), não
     * checa null e não devolve 404 — nada disso é trabalho dele.
     */
    public function show(Categoria $categoria, Produto $produto): View
    {
        return view('produtos.show', [
            'categoria' => $categoria,
            // Com scopeBindings na rota, este produto É da categoria: a
            // consulta foi feita a partir do relacionamento.
            'produto'   => $produto->load('imagens'),
        ]);
    }
}

// ---------------------------------------- tests/Feature/BindingTest.php
declare(strict_types=1);

namespace Tests\Feature;

use App\Models\Categoria;
use App\Models\Produto;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class BindingTest extends TestCase
{
    use RefreshDatabase;

    public function test_categoria_inexistente_devolve_404(): void
    {
        $this->get('/categorias/nao-existe')->assertNotFound();
    }

    public function test_resolve_por_slug(): void
    {
        $categoria = Categoria::factory()->create(['slug' => 'livros']);

        $this->get('/categorias/livros')
            ->assertOk()
            ->assertSee($categoria->nome);
    }

    public function test_produto_de_outra_categoria_nao_vaza(): void
    {
        $livros = Categoria::factory()->create(['slug' => 'livros']);
        $outra  = Categoria::factory()->create(['slug' => 'games']);

        $produto = Produto::factory()->for($outra)->create();

        // Este é o teste que justifica o scopeBindings(): sem ele, a
        // resposta seria 200 e o produto apareceria sob a categoria errada.
        $this->get("/categorias/{$livros->slug}/produtos/{$produto->id}")
            ->assertNotFound();
    }

    public function test_excluido_nao_aparece_sem_with_trashed(): void
    {
        $categoria = Categoria::factory()->create(['slug' => 'antiga']);
        $categoria->delete();

        $this->get('/categorias/antiga')->assertNotFound();
    }
}

Exercício 3

Implemente uma interface CategoriaRepositorioInterface com método listarAtivas(): Collection. Crie a implementação com Eloquent. Registre o binding no AppServiceProvider. Injete no controller.

Ver resposta

✓ Resposta: O ganho não é "trocar o banco um dia" — quase ninguém troca. É o teste: com a interface no lugar, o $this->app->instance(...) injeta um repositório em memória e a suíte roda sem migration, sem RefreshDatabase e em milissegundos. E repare no que a interface não tem: nada de where() ou paginate(). Interface que expõe o query builder só mudou o Eloquent de lugar.

<?php

// ------------------- app/Repositories/CategoriaRepositorioInterface.php
declare(strict_types=1);

namespace App\Repositories;

use App\Models\Categoria;
use Illuminate\Support\Collection;

/**
 * A interface fala o vocabulário do NEGÓCIO, não o do Eloquent. Nada de
 * `where()`, `orderBy()` ou `paginate()` aqui: métodos assim só empurrariam
 * o query builder para dentro do controller com outro nome.
 */
interface CategoriaRepositorioInterface
{
    /** @return Collection<int, Categoria> */
    public function listarAtivas(): Collection;

    public function encontrarPorSlug(string $slug): ?Categoria;

    /** @return Collection<int, Categoria> com a contagem de produtos carregada */
    public function listarComTotais(): Collection;
}

// ------------------- app/Repositories/CategoriaRepositorioEloquent.php
declare(strict_types=1);

namespace App\Repositories;

use App\Models\Categoria;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Support\Collection;

class CategoriaRepositorioEloquent implements CategoriaRepositorioInterface
{
    public function __construct(private readonly Cache $cache) {}

    public function listarAtivas(): Collection
    {
        // O cache mora aqui, não no controller: quem chama pede "as ativas"
        // e não precisa saber que existe cache no caminho.
        return $this->cache->remember('categorias:ativas', now()->addHour(), function (): Collection {
            return Categoria::query()
                ->where('ativa', true)
                ->orderBy('ordem')
                ->orderBy('nome')
                ->get();
        });
    }

    public function encontrarPorSlug(string $slug): ?Categoria
    {
        return Categoria::query()->where('slug', $slug)->first();
    }

    public function listarComTotais(): Collection
    {
        return Categoria::query()
            ->withCount(['produtos as produtos_ativos_count' => function ($consulta): void {
                $consulta->where('ativo', true);
            }])
            ->orderByDesc('produtos_ativos_count')
            ->get();
    }
}

// ------------------- app/Repositories/CategoriaRepositorioEmMemoria.php
declare(strict_types=1);

namespace App\Repositories;

use App\Models\Categoria;
use Illuminate\Support\Collection;

/**
 * A recompensa da interface: teste de unidade sem banco, sem migration e
 * sem RefreshDatabase. Roda em milissegundos.
 */
class CategoriaRepositorioEmMemoria implements CategoriaRepositorioInterface
{
    /** @param Collection<int, Categoria> $categorias */
    public function __construct(private Collection $categorias) {}

    public function listarAtivas(): Collection
    {
        return $this->categorias
            ->where('ativa', true)
            ->sortBy('ordem')
            ->values();
    }

    public function encontrarPorSlug(string $slug): ?Categoria
    {
        return $this->categorias->firstWhere('slug', $slug);
    }

    public function listarComTotais(): Collection
    {
        return $this->categorias;
    }
}

// ---------------------------------------- app/Providers/AppServiceProvider.php
declare(strict_types=1);

namespace App\Providers;

use App\Repositories\CategoriaRepositorioEloquent;
use App\Repositories\CategoriaRepositorioInterface;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    /**
     * Atalho para o binding simples: o container lê este array e liga
     * interface → implementação sem precisar de closure.
     *
     * @var array<class-string, class-string>
     */
    public array $bindings = [
        CategoriaRepositorioInterface::class => CategoriaRepositorioEloquent::class,
    ];

    public function register(): void
    {
        // A forma explícita, para quando houver decisão a tomar:
        //
        // $this->app->singleton(CategoriaRepositorioInterface::class, function ($app) {
        //     return config('loja.repositorio_em_memoria')
        //         ? new CategoriaRepositorioEmMemoria(collect())
        //         : new CategoriaRepositorioEloquent($app->make('cache.store'));
        // });
        //
        // bind()      → instância nova a cada resolução
        // singleton() → uma instância por request
        // scoped()    → uma por request também, mas reiniciada entre jobs
        //               do Octane/Horizon (é o que você quer com estado)
    }

    public function boot(): void
    {
        //
    }
}

// ---------------------------------------- app/Http/Controllers/VitrineController.php
declare(strict_types=1);

namespace App\Http\Controllers;

use App\Repositories\CategoriaRepositorioInterface;
use Illuminate\View\View;

class VitrineController extends Controller
{
    // O controller depende da INTERFACE. Trocar Eloquent por chamada de API,
    // por MongoDB ou por um dublê de teste não toca nesta classe.
    public function __construct(
        private readonly CategoriaRepositorioInterface $categorias,
    ) {}

    public function index(): View
    {
        return view('vitrine.index', [
            'categorias' => $this->categorias->listarAtivas(),
        ]);
    }

    public function porCategoria(string $slug): View
    {
        $categoria = $this->categorias->encontrarPorSlug($slug)
            ?? abort(404, 'Categoria não encontrada.');

        return view('vitrine.categoria', compact('categoria'));
    }
}

// ---------------------------------------- tests/Unit/VitrineTest.php
declare(strict_types=1);

namespace Tests\Unit;

use App\Models\Categoria;
use App\Repositories\CategoriaRepositorioEmMemoria;
use App\Repositories\CategoriaRepositorioInterface;
use Tests\TestCase;

class VitrineTest extends TestCase
{
    public function test_vitrine_lista_apenas_categorias_ativas(): void
    {
        // makeOne() cria o model SEM tocar no banco — é o `create()` sem
        // INSERT. Combinado com o repositório em memória, o teste inteiro
        // roda sem uma única consulta.
        $repositorio = new CategoriaRepositorioEmMemoria(collect([
            Categoria::factory()->makeOne(['nome' => 'Livros', 'ativa' => true,  'ordem' => 1]),
            Categoria::factory()->makeOne(['nome' => 'Games',  'ativa' => false, 'ordem' => 2]),
        ]));

        // A troca acontece no container: o controller continua pedindo a
        // interface e nem fica sabendo.
        $this->app->instance(CategoriaRepositorioInterface::class, $repositorio);

        $this->get('/')
            ->assertOk()
            ->assertSee('Livros')
            ->assertDontSee('Games');
    }
}

Exercício 4

Desafio: crie um comando Artisan customizado php artisan categorias:importar {arquivo} que leia um CSV e importe categorias no banco, exibindo progresso com $this->progressBar().

Ver resposta

✓ Resposta: Três decisões que separam um comando de produção de um script de uma vez só. O generator com fgetcsv: um CSV de 2 GB consome a mesma memória de um de 2 KB. A transação por linha, não por arquivo — uma linha ruim não pode desfazer 9.999 boas, nem manter lock aberto por minutos. E o código de saída: self::FAILURE é o que faz cron, CI e supervisor perceberem o problema. De brinde, a armadilha que consome uma tarde: o BOM do Excel gruda três bytes invisíveis na primeira coluna, e a mensagem vira "coluna nome não encontrada" num arquivo visivelmente correto.

<?php

// ============ no terminal ============
// php artisan make:command ImportarCategorias
//
// Uso:
//   php artisan categorias:importar storage/app/categorias.csv
//   php artisan categorias:importar arquivo.csv --simular
//   php artisan categorias:importar arquivo.csv --atualizar --delimitador=";"

// ---------------------------------------- app/Console/Commands/ImportarCategorias.php
declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\Categoria;
use Generator;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;

class ImportarCategorias extends Command
{
    // {arquivo} obrigatório · {--flag} booleana · {--opcao=} com valor
    protected $signature = 'categorias:importar
                            {arquivo : Caminho do CSV a importar}
                            {--atualizar : Atualiza a categoria que já existir, em vez de pular}
                            {--simular : Lê e valida sem gravar nada}
                            {--delimitador=, : Separador de campos do CSV}';

    protected $description = 'Importa categorias de um arquivo CSV';

    public function handle(): int
    {
        $arquivo = (string) $this->argument('arquivo');

        if (!is_readable($arquivo)) {
            $this->error("Arquivo não encontrado ou sem permissão de leitura: {$arquivo}");

            // Código de saída ≠ 0 é o que faz o cron, o CI e o supervisor
            // perceberem a falha. Devolver 0 sempre é dizer "deu certo".
            return self::FAILURE;
        }

        if ($this->option('simular')) {
            $this->warn('Modo simulação: nada será gravado.');
        }

        // Conta as linhas antes só para dimensionar a barra — em arquivo
        // grande vale mais que a memória que custa (é uma leitura sequencial
        // sem guardar nada).
        $total = $this->contarLinhas($arquivo);

        if ($total === 0) {
            $this->warn('O arquivo não tem nenhuma linha de dados.');

            return self::SUCCESS;
        }

        $this->info("Importando {$total} categoria(s) de {$arquivo}");

        $barra = $this->output->createProgressBar($total);
        $barra->setFormat(" %current%/%max% [%bar%] %percent:3s%%  %message%");
        $barra->setMessage('iniciando...');
        $barra->start();

        $criadas = $atualizadas = $puladas = 0;
        $erros = [];

        foreach ($this->linhas($arquivo) as $numero => $linha) {
            $barra->setMessage(Str::limit((string) ($linha['nome'] ?? '?'), 30));

            try {
                $resultado = $this->importarLinha($linha);

                match ($resultado) {
                    'criada'     => $criadas++,
                    'atualizada' => $atualizadas++,
                    default      => $puladas++,
                };
            } catch (Throwable $e) {
                // Uma linha ruim não derruba a importação inteira: registra
                // e segue. O relatório do fim é que decide o que fazer.
                $erros[] = "linha {$numero}: {$e->getMessage()}";
            }

            $barra->advance();
        }

        $barra->setMessage('concluído');
        $barra->finish();

        $this->newLine(2);

        $this->table(
            ['Criadas', 'Atualizadas', 'Puladas', 'Erros'],
            [[$criadas, $atualizadas, $puladas, count($erros)]],
        );

        if ($erros !== []) {
            $this->newLine();
            $this->error('Linhas com problema:');

            foreach (array_slice($erros, 0, 20) as $erro) {
                $this->line("  · {$erro}");
            }

            if (count($erros) > 20) {
                $this->line('  … e mais ' . (count($erros) - 20) . ' erro(s).');
            }

            return self::FAILURE;
        }

        $this->info('Importação concluída sem erros.');

        return self::SUCCESS;
    }

    /**
     * Generator: lê UMA linha por vez. Com fgetcsv em vez de
     * file_get_contents, um CSV de 2 GB usa a mesma memória de um de 2 KB.
     *
     * @return Generator<int, array<string, string>>
     */
    private function linhas(string $arquivo): Generator
    {
        $ponteiro = fopen($arquivo, 'r');

        if ($ponteiro === false) {
            return;
        }

        try {
            $delimitador = (string) $this->option('delimitador');
            $cabecalho = fgetcsv($ponteiro, separator: $delimitador);

            if ($cabecalho === false) {
                return;
            }

            // BOM do Excel: os três bytes invisíveis grudam na primeira
            // coluna e transformam "nome" em "\u{FEFF}nome" — daí o
            // "coluna nome não encontrada" em arquivo visivelmente correto.
            $cabecalho[0] = preg_replace('/^\x{FEFF}/u', '', (string) $cabecalho[0]) ?? '';
            $cabecalho = array_map(
                static fn (?string $c): string => Str::slug(trim((string) $c), '_'),
                $cabecalho,
            );

            $numero = 1;

            while (($colunas = fgetcsv($ponteiro, separator: $delimitador)) !== false) {
                $numero++;

                // Linha em branco no fim do arquivo é o caso mais comum.
                if ($colunas === [null] || $colunas === []) {
                    continue;
                }

                // Linha com menos colunas que o cabeçalho quebraria o
                // array_combine — completa com string vazia.
                $colunas = array_pad($colunas, count($cabecalho), '');

                yield $numero => array_combine(
                    $cabecalho,
                    array_map(static fn ($v): string => trim((string) $v), $colunas),
                );
            }
        } finally {
            fclose($ponteiro);
        }
    }

    /** @param array<string, string> $linha */
    private function importarLinha(array $linha): string
    {
        $nome = $linha['nome'] ?? '';

        if ($nome === '') {
            throw new \RuntimeException('coluna "nome" vazia');
        }

        $slug = $linha['slug'] ?? '' ?: Str::slug($nome);

        $existente = Categoria::query()->where('slug', $slug)->first();

        if ($existente !== null && !$this->option('atualizar')) {
            return 'pulada';
        }

        if ($this->option('simular')) {
            return $existente !== null ? 'atualizada' : 'criada';
        }

        $atributos = [
            'nome'      => $nome,
            'descricao' => $linha['descricao'] ?? null,
            'ativa'     => filter_var($linha['ativa'] ?? '1', FILTER_VALIDATE_BOOL),
            'ordem'     => (int) ($linha['ordem'] ?? 0),
        ];

        // Transação por linha: a importação inteira numa transação só
        // manteria um lock aberto por minutos e desfaria 9.999 linhas boas
        // por causa de uma ruim.
        return DB::transaction(function () use ($existente, $slug, $atributos): string {
            if ($existente !== null) {
                $existente->update($atributos);

                return 'atualizada';
            }

            Categoria::create([...$atributos, 'slug' => $slug]);

            return 'criada';
        });
    }

    private function contarLinhas(string $arquivo): int
    {
        $ponteiro = fopen($arquivo, 'r');

        if ($ponteiro === false) {
            return 0;
        }

        $linhas = 0;

        while (fgets($ponteiro) !== false) {
            $linhas++;
        }

        fclose($ponteiro);

        return max(0, $linhas - 1);   // menos o cabeçalho
    }
}

// ---------------------------------------- tests/Feature/ImportarCategoriasTest.php
declare(strict_types=1);

namespace Tests\Feature;

use App\Models\Categoria;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class ImportarCategoriasTest extends TestCase
{
    use RefreshDatabase;

    private function csv(string $conteudo): string
    {
        Storage::fake('local');
        Storage::disk('local')->put('categorias.csv', $conteudo);

        return Storage::disk('local')->path('categorias.csv');
    }

    public function test_importa_e_relata(): void
    {
        $caminho = $this->csv(<<<CSV
            nome,slug,descricao,ativa,ordem
            Livros,livros,Livros em geral,1,1
            Games,games,Jogos,1,2
            CSV);

        $this->artisan("categorias:importar {$caminho}")
            ->expectsOutputToContain('Importação concluída sem erros.')
            ->assertSuccessful();

        $this->assertDatabaseCount('categorias', 2);
        $this->assertDatabaseHas('categorias', ['slug' => 'livros']);
    }

    public function test_simular_nao_grava(): void
    {
        $caminho = $this->csv("nome,slug\nLivros,livros\n");

        $this->artisan("categorias:importar {$caminho} --simular")->assertSuccessful();

        $this->assertDatabaseCount('categorias', 0);
    }

    public function test_linha_invalida_nao_derruba_a_importacao(): void
    {
        $caminho = $this->csv("nome,slug\nLivros,livros\n,sem-nome\nGames,games\n");

        // Sai com FAILURE por causa da linha ruim, mas as duas boas entraram.
        $this->artisan("categorias:importar {$caminho}")->assertFailed();

        $this->assertDatabaseCount('categorias', 2);
    }

    public function test_arquivo_inexistente_falha(): void
    {
        $this->artisan('categorias:importar /nao/existe.csv')->assertFailed();
    }

    public function test_atualizar_altera_a_existente(): void
    {
        Categoria::factory()->create(['slug' => 'livros', 'nome' => 'Antigo']);

        $caminho = $this->csv("nome,slug\nLivros Novos,livros\n");

        $this->artisan("categorias:importar {$caminho} --atualizar")->assertSuccessful();

        $this->assertDatabaseHas('categorias', ['slug' => 'livros', 'nome' => 'Livros Novos']);
    }
}

Exercício 5

Num arquivo routes/web.php, registre nesta ordem: Route::get('/produtos/{id}', fn ($id) => 'closure'), depois Route::get('/produtos/{produto}', [ProdutoController::class, 'show']), depois Route::resource('produtos', ProdutoController::class) e, por fim, o mesmo Route::resource dentro de um grupo com prefix('admin'). Rode php artisan route:list e responda três perguntas: quem atende GET /produtos/5? Quantas rotas GET produtos/{produto} sobraram na lista? E para onde aponta route('produtos.index') — /produtos ou /admin/produtos?

Ver resposta

✓ Resposta: Quem atende GET /produtos/5 é a closure, e o show() do controller nunca roda. Sobrou uma só rota GET produtos/{produto} — a do resource comeu a registrada à mão. E route('produtos.index') devolve /produtos: as rotas do admin existem, respondem por URL, mas não são alcançáveis pelo nome. São três comportamentos diferentes disparados pela mesma causa, e nenhum deles avisa.

O primeiro é a regra de despacho: o Laravel percorre a lista e para na primeira rota que casa. /produtos/{id} e /produtos/{produto} são a mesma URI para quem chega — o nome do parâmetro é só o rótulo do argumento —, então a que foi registrada antes ganha. O segundo é distinto: rotas com o mesmo método e a mesma URI compartilham a chave interna da coleção, e a última registrada substitui a anterior. O terceiro está na fonte do framework, em RouteCollection::addLookups():

<?php
// vendor/illuminate/routing/RouteCollection.php
if (($name = $route->getName()) && ! $this->inNameLookup($name)) {
    $this->nameList[$name] = $route;
}

O ! $this->inNameLookup($name) é a resposta inteira: nome já tomado é nome ignorado, em silêncio. Não há exceção, não há aviso no route:list. Medido no illuminate/routing 13.32:

<?php
// Com a ordem do enunciado:
route('produtos.show', 42);         // http://localhost/produtos/42   ← público
route('admin.produtos.show', 42);   // RouteNotFoundException         ← não existe

// Com `names()` no grupo do admin:
Route::middleware('auth')->prefix('admin')->group(function () {
    Route::resource('produtos', ProdutoController::class)->names('admin.produtos');
});

route('produtos.show', 42);         // http://localhost/produtos/42
route('admin.produtos.show', 42);   // http://localhost/admin/produtos/42

Daí saem três hábitos que evitam a tarde inteira de depuração. Rota concreta antes de rota com parâmetro, sempre — /produtos/destaques depois de /produtos/{id} vira um produto de id "destaques". Ou o resource, ou as rotas na mão, nunca os dois para o mesmo recurso. E todo grupo com prefixo leva prefixo de nome também: ->names('admin.produtos'), ou ->name('admin.') no grupo inteiro. O php artisan route:list mostra as três situações de uma vez — vale abri-lo depois de mexer nas rotas, e não só quando algo já quebrou.

Comentários

Mais em PHP

Orientação a Objetos: Fundamentos
Orientação a Objetos: Fundamentos

Uma classe define o molde; cada objeto guarda o próprio estado. Construtor…

Funções
Funções

Uma função guarda um trecho de lógica sob um nome e um contrato. Como declarar…

Eloquent ORM com Laravel
Eloquent ORM com Laravel

Migrations, fillable e casts, os quatro tipos de relacionamento, factories e…