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
- Documentação oficial do Laravel: https://laravel.com/docs
- Laracasts (vídeos): https://laracasts.com/
- Laravel News: https://laravel-news.com/
- Awesome Laravel: https://github.com/chiraggude/awesome-laravel
- Laravel — instalacao e requisitos, incluindo o Sail para ambiente em Docker: https://laravel.com/docs/installation
- Laravel — notas de versao e politica de suporte: qual release ainda recebe correcao de seguranca: https://laravel.com/docs/releases
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.