Trilha prática · Laravel 13

Aprenda Laravel construindo um Sistema de Usuários

28 aulas. Em cada uma você aprende um conceito e escreve um pedaço real do sistema — no final ele estará completo e funcionando.

O projeto que você vai construir

Ao longo das aulas você vai montar, peça por peça, um painel de usuários simples. Não precisa adiantar nada agora — cada recurso abaixo vai aparecer exatamente na aula em que o conceito correspondente for ensinado. Isso aqui é só o "mapa" do destino final:

AuthGateEvent/ListenerMailHTTP ClientJobs/ScheduleSeeder

Básico

Aulas 1–5

O que é o Laravel, rapidamente

Laravel é um framework (conjunto de ferramentas e convenções prontas que aceleram o desenvolvimento, em vez de você escrever tudo do zero) para a linguagem PHP. Ele organiza o código seguindo o padrão MVC (Model-View-Controller): o Model cuida dos dados e das regras de negócio, a View cuida da tela que o usuário vê, e o Controller é a "cola" que recebe o pedido, conversa com o Model e decide qual View mostrar.

Conceito de PHP — função e closureVocê vai ver bastante código PHP entre function () { ... }. Isso é uma função anônima (também chamada de closure) — um bloco de código sem nome, que pode ser guardado numa variável ou passado como argumento pra outra função, e executado depois. É exatamente assim que o Laravel deixa você "anexar" um comportamento a uma rota: você não dá nome pra essa função, só entrega ela pronta pro Laravel chamar quando alguém acessar aquela URL.

Instalação

terminal
composer create-project laravel/laravel:^13.0 painel-usuarios
cd painel-usuarios
php artisan serve

composer é o gerenciador de dependências (pacotes/bibliotecas prontas de outras pessoas que seu projeto pode usar) do PHP — equivalente ao npm do Node.js, se você já ouviu falar. artisan é a CLI (command-line interface, "interface de linha de comando" — um programa que você controla digitando comandos no terminal, sem tela gráfica) própria do Laravel. Depois do php artisan serve, seu projeto fica disponível em http://localhost:8000.

Estrutura de pastas essencial

estrutura
app/
  Http/
    Controllers/   → classes que recebem a requisição e decidem o que fazer
  Models/          → classes que representam tabelas do banco
routes/
  web.php          → rotas para páginas (navegador)
database/
  migrations/      → "receitas" versionadas de como o banco deve ser
resources/
  views/           → arquivos Blade (templates HTML)
public/            → único diretório exposto ao navegador

A pasta public/ — a única porta de entrada

Quando você configura um servidor de verdade (Apache, Nginx) pra rodar seu projeto Laravel em produção, o document root (a pasta que o servidor HTTP aponta como raiz pública) deve ser sempre a pasta public/ — nunca a raiz do projeto inteiro. Dentro dela fica o index.php, o único ponto de entrada de toda a aplicação.

O motivo é segurança: tudo fora de public/ — sua pasta app/, o arquivo .env com senhas e chaves de API — fica inacessível diretamente pelo navegador. Em desenvolvimento local, php artisan serve já cuida disso sozinho; na hora de subir pra produção, configurar isso certo é obrigatório.

Roteamento

Rota é a definição de "quando alguém acessar essa URL com esse método HTTP, execute isso". Fica em routes/web.php. Vamos criar as três rotas que vão sustentar o projeto inteiro.

routes/web.php
use Illuminate\Support\Facades\Route;

Route::get('/usuarios', function () {
    return 'Aqui vai a lista de usuários';
});

Se você acessar http://localhost:8000/usuarios, verá esse texto. Isso confirma que a rota está funcionando — próximas aulas vão trocar esse texto solto por um Controller de verdade.

Verbos HTTP — cada ação tem um verbo próprio:

routes/web.php
Route::get('/usuarios', ...);         // ver a lista
Route::get('/usuarios/criar', ...);   // ver o formulário de cadastro
Route::post('/usuarios', ...);        // salvar um usuário novo

GET é usado quando o navegador só está pedindo pra ver algo (uma página, uma lista). POST é usado quando o navegador está enviando dados pro servidor processar (como o conteúdo de um formulário).

Mão na massa

Instale o projeto e crie, em routes/web.php, as três rotas acima: GET /usuarios, GET /usuarios/criar e POST /usuarios. Pra cada uma, retorne por enquanto só um texto simples identificando qual rota é (ex: return 'Formulário de cadastro';). Acesse cada URL no navegador (a POST você só consegue testar mais pra frente, quando tiver um formulário de verdade) pra confirmar que todas respondem.

Dica Se aparecer uma página de erro 419 ou 405 na rota POST, não se preocupe — isso é esperado por enquanto, porque ainda não existe formulário nenhum mandando dados pra ela. Vamos resolver isso na Aula 5.
Conceito de PHP — classe, objeto e métodoUma classe é um "molde" que descreve um tipo de coisa — o que ela sabe (suas propriedades) e o que ela sabe fazer (seus métodos, que são funções que pertencem à classe). Um objeto é uma instância real, criada a partir desse molde (new NomeDaClasse()). Um método é só o nome que damos a uma função quando ela está dentro de uma classe. No Laravel, um Controller é uma classe, e cada uma das suas ações (listar, criar, salvar) é um método dessa classe.

O que é um Controller

Controller (controlador) é uma classe que agrupa a lógica que responde a uma requisição (o pedido que o navegador faz ao servidor). Em vez de escrever a lógica direto dentro da rota (como fizemos na Aula 1, só pra testar), organizamos por responsabilidade — o UsuarioController vai cuidar de tudo relacionado a usuários.

Criando o Controller

terminal
php artisan make:controller UsuarioController

Isso cria o arquivo app/Http/Controllers/UsuarioController.php, já com a estrutura básica de uma classe vazia.

Estrutura básica

app/Http/Controllers/UsuarioController.php
namespace App\Http\Controllers;

class UsuarioController extends Controller
{
    public function index()
    {
        return 'Lista de usuários';
    }

    public function create()
    {
        return 'Formulário de cadastro';
    }

    public function store()
    {
        return 'Usuário salvo';
    }
}

extends Controller significa que UsuarioController "herda" características da classe base Controller do Laravel — é assim que toda classe Controller do seu projeto ganha, de graça, alguns recursos internos do framework.

Repare que os três métodos (index, create, store) já batem exatamente com as três ações do projeto: index lista, create mostra o formulário, store salva. Esses nomes não são obrigatórios, mas são a convenção que o próprio Laravel usa — vamos ver isso com mais detalhe daqui a pouco.

Apontando as rotas pro Controller

routes/web.php
use App\Http\Controllers\UsuarioController;

Route::get('/usuarios', [UsuarioController::class, 'index']);
Route::get('/usuarios/criar', [UsuarioController::class, 'create']);
Route::post('/usuarios', [UsuarioController::class, 'store']);

[UsuarioController::class, 'index'] diz: "quando bater nessa rota, instancie o UsuarioController e chame o método index". UsuarioController::class é só um jeito seguro de escrever o nome completo da classe (com o namespace todo) sem digitar errado.

Controller Resource — o atalho pra CRUD

CRUD é a sigla pra Create, Read, Update, Delete — as 4 operações básicas sobre um dado. Como nosso Controller já segue a convenção de nomes do Laravel, dá pra trocar as três linhas de rota acima por uma única linha:

routes/web.php
Route::resource('usuarios', UsuarioController::class);

Isso cria automaticamente 7 rotas (incluindo edit, update e destroy, que vamos usar mais pra frente) — todas seguindo o padrão REST (um estilo de organizar rotas em torno de "recursos", como "usuários", combinado com os verbos HTTP). Por enquanto, pode usar as três rotas explícitas da Aula 1 mesmo, pra deixar mais claro o que está acontecendo; trocamos pelo Route::resource mais adiante.

Request — os dados que chegam num formulário

Request é o objeto que carrega tudo que o navegador enviou: campos de formulário, arquivos, etc. Vamos usar isso de verdade a partir da Aula 5, mas o formato básico é:

exemplo
use Illuminate\Http\Request;

public function store(Request $request)
{
    $nome = $request->input('nome'); // ou forma mais curta: $request->nome
}

Mão na massa

Crie o UsuarioController com os três métodos index, create e store, cada um retornando um texto identificador (como no exemplo). Troque as rotas da Aula 1 pra apontar pro Controller, exatamente como no bloco "Apontando as rotas pro Controller". Confirme no navegador que /usuarios e /usuarios/criar continuam respondendo, agora vindo do Controller.

O que é uma View

View (visão) é o arquivo que gera o HTML que o usuário vê. Fica em resources/views/. No Laravel, views normalmente são escritas com Blade, o motor de templates (uma linguagem que mistura HTML com pedacinhos de PHP, com sintaxe mais limpa que PHP puro).

Retornando uma view

app/Http/Controllers/UsuarioController.php
public function index()
{
    return view('usuarios.index');
}

public function create()
{
    return view('usuarios.criar');
}

O ponto em 'usuarios.index' representa uma subpasta: isso busca o arquivo resources/views/usuarios/index.blade.php.

Layout base — pra não repetir HTML em toda página

resources/views/layouts/app.blade.php
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>@yield('titulo') — Painel de Usuários</title>
</head>
<body>
    <nav>Painel de Usuários</nav>

    <main>
        @yield('conteudo')
    </main>
</body>
</html>

@yield('nome') é um "espaço reservado" que cada página vai preencher com seu próprio conteúdo.

A tela de lista

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

@section('titulo', 'Usuários')

@section('conteudo')
    <h1>Usuários cadastrados</h1>

    <ul>
    @foreach ($usuarios as $usuario)
        <li>{{ $usuario->name }} — nascido em {{ $usuario->data_nascimento }}</li>
    @endforeach
    </ul>
@endsection

@extends('layouts.app') diz "esta view usa aquele layout como base". {{ $usuario->name }} exibe o valor da propriedade (com escape automático — proteção contra XSS, um ataque onde código malicioso é injetado numa página). @foreach repete o bloco de HTML uma vez pra cada item da lista de usuários. Ainda não temos $usuarios vindo de lugar nenhum — isso vem do banco de dados, que só vamos construir na Aula 4.

O formulário de cadastro

resources/views/usuarios/criar.blade.php
@extends('layouts.app')

@section('titulo', 'Cadastrar usuário')

@section('conteudo')
    <h1>Cadastrar novo usuário</h1>

    <form method="POST" action="/usuarios">
        @csrf

        <label>Nome</label>
        <input type="text" name="name">

        <label>E-mail</label>
        <input type="email" name="email">

        <label>Senha</label>
        <input type="password" name="password">

        <label>Data de nascimento</label>
        <input type="date" name="data_nascimento">

        <label>Papel</label>
        <select name="papel">
            <option value="comum">Comum</option>
            <option value="gerente">Gerente</option>
            <option value="admin">Admin</option>
        </select>

        <button type="submit">Cadastrar</button>
    </form>
@endsection

@csrf vamos explicar em detalhe na Aula 5 — por enquanto, saiba que sem essa linha o formulário simplesmente não funciona, então não esqueça dela.

Mão na massa

Crie o layout layouts/app.blade.php e as duas views (usuarios/index.blade.php e usuarios/criar.blade.php) exatamente como nos exemplos. Ajuste o Controller pra retornar view('usuarios.criar') no método create. Acesse /usuarios/criar no navegador — o formulário deve aparecer (sem estilo bonito ainda, isso é só HTML cru). O método index ainda vai dar erro, porque falta o $usuarios — vamos resolver isso já na próxima aula.

Conceito de PHP — arrayUm array é uma "caixa" que guarda vários valores organizados. Pode ser uma lista simples (['a', 'b', 'c']) ou um array associativo, onde cada valor tem um nome/chave (['nome' => 'Ana', 'idade' => 30]). Você vai ver arrays associativos o tempo todo no Laravel — por exemplo, pra listar quais campos um formulário pode preencher.

O que é ORM

ORM (Object-Relational Mapping, mapeamento objeto-relacional) é uma técnica onde você manipula tabelas do banco de dados como se fossem classes e objetos do PHP, sem escrever SQL manualmente na maioria dos casos. No Laravel, esse ORM se chama Eloquent.

Boa notícia: o model de usuário já existe

Todo projeto Laravel novo já vem, por padrão, com um model User (em app/Models/User.php) e uma migration criando a tabela users — porque autenticação de usuário é algo tão comum que o framework já vem preparado. Em vez de criar um model separado do zero, vamos estender esse que já existe, adicionando as colunas que nosso projeto precisa: papel, data_nascimento e criador_id (pra saber quem cadastrou quem).

Migrations (migrações)

Migration é um arquivo PHP que descreve, de forma versionada, como o banco deve ser — como um "controle de versão" (git) só que pra estrutura do banco. A migration da tabela users já existe (database/migrations/..._create_users_table.php); vamos criar uma nova migration que só adiciona colunas nela.

terminal
php artisan make:migration add_papel_e_nascimento_to_users_table --table=users
database/migrations/..._add_papel_e_nascimento_to_users_table.php
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::table('users', function (Blueprint $table) {
            $table->string('papel')->default('comum');     // 'admin', 'gerente' ou 'comum'
            $table->date('data_nascimento')->nullable();
            $table->foreignId('criador_id')->nullable()->constrained('users');
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn(['papel', 'data_nascimento', 'criador_id']);
        });
    }
};

Repare a diferença de Schema::create (cria uma tabela nova do zero) pra Schema::table (abre uma tabela que já existe pra adicionar/remover colunas). nullable() permite que o campo fique vazio (nem todo usuário precisa ter um "criador" — o primeiro admin, por exemplo, não foi criado por ninguém). foreignId('criador_id')->constrained('users') cria uma chave estrangeira que aponta pra própria tabela users — um usuário referenciando outro usuário.

Rodando a migration (aplica a mudança no banco de fato):

terminal
php artisan migrate

Ajustando o Model User

app/Models/User.php
protected $fillable = [
    'name',
    'email',
    'password',
    'papel',
    'data_nascimento',
    'criador_id',
];

$fillable é a lista de campos que podem ser preenchidos em massa (mass assignment: atribuir vários campos de uma vez, tipo User::create($dados)) — é uma proteção de segurança, sem isso o Eloquent recusa preencher os campos por padrão.

Operações básicas (CRUD com Eloquent)

exemplo
// Criar
$usuario = User::create([
    'name' => 'Ana Souza',
    'email' => 'ana@exemplo.com',
    'password' => bcrypt('senha123'), // nunca salve senha em texto puro!
    'papel' => 'comum',
    'data_nascimento' => '1998-03-14',
]);

// Ler
$todos = User::all();
$um = User::find(1);
$primeiro = User::where('email', 'ana@exemplo.com')->first();

bcrypt() é uma função do Laravel que transforma a senha num hash (uma "impressão digital" de tamanho fixo, impossível de reverter pra descobrir a senha original) — é assim que o Laravel guarda senhas com segurança, nunca em texto puro.

Preenchendo a lista de usuários

Agora dá pra completar o método index que deixamos incompleto na Aula 3:

app/Http/Controllers/UsuarioController.php
use App\Models\User;

public function index()
{
    $usuarios = User::all();
    return view('usuarios.index', compact('usuarios'));
}

compact('usuarios') é uma função do PHP que empacota a variável $usuarios num array associativo (['usuarios' => $usuarios]) automaticamente, usando o nome da variável como chave — assim a view recebe uma variável chamada $usuarios.

Mão na massa

1) Crie e rode a migration que adiciona papel, data_nascimento e criador_id na tabela users. 2) Atualize o $fillable do model User. 3) Complete o método index do Controller pra buscar todos os usuários e passar pra view. 4) Pra testar sem precisar do formulário ainda, use o Tinker (console interativo do Laravel) pra criar um usuário na mão:

terminal
php artisan tinker
dentro do tinker
User::create(['name' => 'Ana Souza', 'email' => 'ana@exemplo.com', 'password' => bcrypt('123456'), 'data_nascimento' => '1998-03-14']);

Acesse /usuarios e confirme que a Ana aparece na lista.

Dica Tinker é um "terminal PHP" que já carrega seu projeto Laravel inteiro — dá pra rodar qualquer código PHP/Eloquent ali direto, ótimo pra testar coisas rápido sem precisar de tela.

CSRF (proteção contra falsificação de requisição)

CSRF (Cross-Site Request Forgery) é um tipo de ataque onde um site malicioso induz o navegador de uma vítima já logada em outro site a enviar uma requisição indesejada. O Laravel se protege exigindo um token CSRF (um código único por sessão) em todo formulário que envia dados via POST — é por isso que a linha @csrf no nosso formulário da Aula 3 é obrigatória: sem ela, o Laravel recusa a requisição com erro 419.

Validação no Controller

Agora vamos completar o método store de verdade: receber os dados do formulário, validar, e salvar.

app/Http/Controllers/UsuarioController.php
use Illuminate\Http\Request;
use App\Models\User;

public function store(Request $request)
{
    $validado = $request->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|unique:users,email',
        'password' => 'required|string|min:6',
        'data_nascimento' => 'required|date',
        'papel' => 'required|in:admin,gerente,comum',
    ]);

    $validado['password'] = bcrypt($validado['password']);

    User::create($validado);

    return redirect('/usuarios');
}

Vamos entender cada regra: required obriga o campo a vir preenchido. email valida o formato de e-mail. unique:users,email garante que não existe outro usuário já cadastrado com esse e-mail (consulta a tabela users, coluna email). min:6 exige pelo menos 6 caracteres na senha. in:admin,gerente,comum só aceita esses três valores exatos pro papel — protege contra alguém mandar um papel inventado pelo formulário.

Se qualquer regra falhar, o Laravel automaticamente redireciona de volta pro formulário, mantendo os dados já digitados e mostrando as mensagens de erro — você não escreve esse comportamento, ele já vem pronto.

Exibindo erros no formulário

resources/views/usuarios/criar.blade.php
<label>Nome</label>
<input type="text" name="name" value="{{ old('name') }}">
@error('name')
    <p style="color: red;">{{ $message }}</p>
@enderror

<label>E-mail</label>
<input type="email" name="email" value="{{ old('email') }}">
@error('email')
    <p style="color: red;">{{ $message }}</p>
@enderror

old('name') recupera o valor digitado antes do erro, pra não obrigar a pessoa a preencher tudo de novo. @error('campo') ... @enderror mostra a mensagem só se aquele campo específico falhou — o Laravel guarda os erros automaticamente numa variável $errors, disponível em qualquer view sem você precisar passar manualmente.

Mão na massa

Complete o método store exatamente como no exemplo. Adicione value="{{ old('...') }}" e @error em todos os campos do formulário de cadastro. Teste três cenários no navegador: (1) enviar o formulário vazio — deve voltar mostrando os erros; (2) enviar com um e-mail que já existe — deve acusar duplicado; (3) preencher tudo certo — deve criar o usuário e te levar de volta pra /usuarios, onde ele já aparece na lista.

Intermediário

Aulas 6–11

O que são relacionamentos

Relacionamento é a forma do Eloquent expressar, em PHP, como as tabelas se conectam entre si (chaves estrangeiras) — sem você escrever JOIN manualmente. Nosso caso é interessante porque é uma tabela se relacionando com ela mesma: um usuário (o criador) pode ter cadastrado vários outros usuários.

belongsTo — "este usuário pertence a um criador"

Lembra da coluna criador_id que criamos na Aula 4? Ela guarda o id de quem cadastrou aquele usuário. Um relacionamento belongsTo (pertence a) expressa isso: "este registro aponta pra um único registro relacionado".

app/Models/User.php
public function criador()
{
    return $this->belongsTo(User::class, 'criador_id');
}

O segundo parâmetro ('criador_id') é necessário aqui porque o Eloquent, por padrão, procuraria uma coluna chamada user_id (baseado no nome da classe relacionada) — como nossa coluna se chama diferente, precisamos avisar explicitamente.

hasMany — "este usuário criou vários outros"

O lado inverso: um hasMany (tem muitos) expressa "este registro tem vários outros registros apontando pra ele".

app/Models/User.php
public function usuariosCriados()
{
    return $this->hasMany(User::class, 'criador_id');
}

Usando os dois relacionamentos

exemplo
$usuario = User::find(3);

$usuario->criador;         // objeto User de quem cadastrou esse usuário (ou null)
$usuario->usuariosCriados; // Collection (coleção) de todos que ESSE usuário cadastrou

Repare: sem parênteses ($usuario->criador), o Eloquent já executa a consulta e devolve o resultado pronto — é a "mágica" das propriedades dinâmicas do Eloquent, baseada no nome do método que você criou.

Mostrando "quem cadastrou" na lista de usuários

resources/views/usuarios/index.blade.php
@foreach ($usuarios as $usuario)
    <li>
        {{ $usuario->name }} — nascido em {{ $usuario->data_nascimento }}
        @if ($usuario->criador)
            (cadastrado por {{ $usuario->criador->name }})
        @endif
    </li>
@endforeach

O @if evita erro quando criador é null (caso do primeiro admin, que não foi cadastrado por ninguém).

Mão na massa

Adicione os métodos criador() e usuariosCriados() no model User. Ajuste a view de lista pra mostrar quem cadastrou cada usuário, quando existir. Pelo Tinker, crie um segundo usuário passando 'criador_id' => 1 (assumindo que a Ana da aula passada tem id 1) e confirme na tela que aparece "cadastrado por Ana Souza".

Consultas com filtro (WHERE)

exemplos
User::where('papel', 'gerente')->get();
User::where('papel', '!=', 'comum')->get();
User::whereMonth('data_nascimento', 3)->get(); // todos que nasceram em março

whereMonth é um atalho do Eloquent pra filtrar só pelo mês de uma coluna de data, ignorando o dia e o ano — vamos usar exatamente esse tipo de filtro na Aula 12, pra achar os aniversariantes do dia.

Query Scopes — empacotando um filtro comum

Scope é um método no Model que encapsula uma condição usada com frequência, evitando repetir o mesmo where em vários lugares do sistema.

app/Models/User.php
public function scopeGerentes($query)
{
    return $query->where('papel', 'gerente');
}

public function scopeAdmins($query)
{
    return $query->where('papel', 'admin');
}

public function scopePodeCadastrar($query)
{
    return $query->whereIn('papel', ['admin', 'gerente']);
}

Repare na convenção: o método começa com scope + nome em PascalCase, mas na hora de usar você chama sem o prefixo e em camelCase:

uso
$gerentes = User::gerentes()->get();
$quemPodeCadastrar = User::podeCadastrar()->get();

Accessor — calculando a idade sem guardar ela no banco

Accessor é um método que transforma um valor na hora de ler o atributo, sem alterar o que está salvo no banco. Idade é um ótimo exemplo: não faz sentido guardar "32 anos" numa coluna, porque isso muda todo ano — melhor calcular na hora, a partir da data_nascimento.

app/Models/User.php
use Illuminate\Database\Eloquent\Casts\Attribute;
use Carbon\Carbon;

protected function idade(): Attribute
{
    return Attribute::make(
        get: fn () => $this->data_nascimento
            ? Carbon::parse($this->data_nascimento)->age
            : null,
    );
}

Carbon é a biblioteca de datas que o Laravel usa por baixo dos panos — Carbon::parse(...)->age já calcula a idade atual a partir de uma data de nascimento, sem você escrever a matemática. Repare que o método se chama idade() (camelCase), mas você acessa como $usuario->idade — o Eloquent converte automaticamente.

Blade
<li>{{ $usuario->name }} — {{ $usuario->idade }} anos</li>

Mão na massa

Adicione os três scopes (scopeGerentes, scopeAdmins, scopePodeCadastrar) e o accessor idade no model User. Mostre a idade calculada na view de lista de usuários, ao lado da data de nascimento.

O que é Middleware

Middleware é uma camada de código que intercepta a requisição antes dela chegar ao Controller. Pense nele como um "segurança na porta": ele decide se deixa a requisição passar, ou barra ali mesmo.

O middleware auth, já pronto no Laravel

Pra exigir que a pessoa esteja logada antes de acessar as telas de usuários, não precisamos criar um middleware do zero — o Laravel já vem com um pronto, chamado auth. Vamos aplicá-lo nas nossas rotas:

routes/web.php
Route::middleware('auth')->group(function () {
    Route::get('/usuarios', [UsuarioController::class, 'index']);
    Route::get('/usuarios/criar', [UsuarioController::class, 'create']);
    Route::post('/usuarios', [UsuarioController::class, 'store']);
});

Route::middleware('auth')->group(function () { ... }) aplica o middleware auth em todas as rotas dentro daquela closure — assim você não repete ->middleware('auth') em cada linha. Se alguém não logado tentar acessar qualquer uma dessas URLs, o Laravel redireciona automaticamente pra tela de login (que ainda vamos configurar na próxima aula).

Fluxo visual, pra fixar

fluxo
Requisição → Middleware "auth" → (logado?) → Controller → Resposta
                              ↳ (não logado?) → Redireciona pro login

Criando seu próprio middleware (pra referência futura)

Ainda não precisamos disso — a Aula 9 vai resolver a regra "só admin/gerente pode cadastrar" com Gate, uma ferramenta mais adequada pra esse tipo de checagem. Mas vale saber como um middleware customizado se pareceria:

terminal
php artisan make:middleware VerificaPapel
exemplo
use Closure;
use Illuminate\Http\Request;

class VerificaPapel
{
    public function handle(Request $request, Closure $next)
    {
        if ($request->user()->papel === 'comum') {
            abort(403, 'Você não tem permissão pra acessar isso.');
        }

        return $next($request);
    }
}

$next($request) é o que deixa a requisição continuar pro Controller; não chamar isso (como no abort acima) interrompe o fluxo ali mesmo.

Mão na massa

Envolva as três rotas de usuários (index, create, store) num grupo com Route::middleware('auth'). Como você ainda não tem sistema de login pronto (isso vem na próxima aula), tente acessar /usuarios agora — o Laravel vai reclamar que a rota de login não existe. É esperado! Vamos resolver isso já na Aula 9.

Diferença entre Autenticação e Autorização

Autenticação responde "quem é você?" (login). Autorização responde "você pode fazer isso?" (permissão, depois que já se sabe quem é a pessoa). São conceitos separados, mas sempre andam juntos — e é exatamente essa dupla que o nosso projeto precisa: primeiro saber quem está logado (Auth), depois decidir se essa pessoa pode cadastrar outra (Gate).

Rotas e tela de login

routes/web.php
use App\Http\Controllers\AuthController;

Route::get('/login', [AuthController::class, 'mostrarFormulario']);
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout']);
terminal
php artisan make:controller AuthController
app/Http/Controllers/AuthController.php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AuthController extends Controller
{
    public function mostrarFormulario()
    {
        return view('auth.login');
    }

    public function login(Request $request)
    {
        $credenciais = $request->validate([
            'email' => 'required|email',
            'password' => 'required',
        ]);

        if (Auth::attempt($credenciais)) {
            $request->session()->regenerate(); // proteção contra "roubo" de sessão antiga
            return redirect('/usuarios');
        }

        return back()->withErrors(['email' => 'Credenciais inválidas.']);
    }

    public function logout(Request $request)
    {
        Auth::logout();
        $request->session()->invalidate();
        return redirect('/login');
    }
}

Auth::attempt() verifica o e-mail e a senha contra a tabela users — ele compara o hash automaticamente, você nunca lida com a senha em texto puro. Se bater, a pessoa fica "logada" (o Laravel guarda isso numa sessão, que é um jeito do servidor lembrar quem é você entre uma requisição e outra).

resources/views/auth/login.blade.php
@extends('layouts.app')
@section('conteudo')
    <form method="POST" action="/login">
        @csrf
        <input type="email" name="email" placeholder="E-mail">
        <input type="password" name="password" placeholder="Senha">
        <button type="submit">Entrar</button>
    </form>
@endsection

Gate — a regra "só admin/gerente cadastra"

Gate é a forma mais simples de autorização: uma função que responde true/false pra uma ação específica. Usamos Gate (em vez de Policy) porque nossa regra é única e simples, não amarrada a um Model específico com várias ações diferentes.

app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Gate;

public function boot(): void
{
    Gate::define('cadastrar-usuarios', function ($user) {
        return in_array($user->papel, ['admin', 'gerente']);
    });
}

in_array() é uma função nativa do PHP que verifica se um valor existe dentro de um array — aqui, se $user->papel é 'admin' ou 'gerente'.

Usando o Gate pra proteger as rotas de cadastro

app/Http/Controllers/UsuarioController.php
public function create()
{
    Gate::authorize('cadastrar-usuarios'); // aborta com 403 automaticamente se negar

    return view('usuarios.criar');
}

public function store(Request $request)
{
    Gate::authorize('cadastrar-usuarios');

    // ... validação e criação, como na Aula 5
}

Gate::authorize() checa a regra e, se ela retornar false, já interrompe a execução e devolve um erro 403 (proibido) — você não precisa escrever o if manualmente.

Escondendo o link de cadastro na tela, pra quem não pode ver

resources/views/usuarios/index.blade.php
@can('cadastrar-usuarios')
    <a href="/usuarios/criar">Cadastrar novo usuário</a>
@endcan

Isso não substitui a checagem no Controller (alguém ainda poderia digitar a URL na mão) — é só uma questão de boa experiência, escondendo um botão que a pessoa não pode usar mesmo.

Mão na massa

Crie o AuthController, as rotas de login/logout e a view auth/login.blade.php. Defina o Gate cadastrar-usuarios no AppServiceProvider e proteja create/store com Gate::authorize(). Esconda o link de cadastro com @can. Teste logando com a Ana (que criamos com papel 'comum' por padrão) e tentando acessar /usuarios/criar direto pela URL — deve dar 403. Depois, pelo Tinker, mude o papel dela pra 'admin' (User::find(1)->update(['papel' => 'admin'])) e teste de novo — agora deve funcionar.

O problema que isso resolve

Daqui a pouco (Aula 13 e 23) vamos precisar mandar uma mensagem de WhatsApp de boas-vindas pro usuário recém-cadastrado, usando um serviço externo chamado evolution-api. Se toda vez que precisarmos disso fizermos new EvolutionApiService(...) na mão, repetimos código e fica difícil trocar a configuração depois. O Service Container resolve isso.

O que é o Service Container

É o "gerente" central do Laravel que sabe como construir qualquer classe da sua aplicação, incluindo suas dependências. Em vez de instanciar manualmente, você pede ao container e ele monta o objeto pra você.

Criando o serviço

app/Services/EvolutionApiService.php
namespace App\Services;

class EvolutionApiService
{
    public function __construct(
        protected string $url,
        protected string $chave,
    ) {}

    public function enviarMensagem(string $numero, string $texto): void
    {
        // a implementação real vem na Aula 23, com HTTP Client
    }
}

public function __construct(protected string $url, protected string $chave) é uma forma resumida do PHP de declarar E já guardar duas propriedades da classe ao mesmo tempo — sem essa abreviação, você teria que escrever $this->url = $url; manualmente dentro do construtor.

Injeção de dependência — pedindo o serviço pronto

Se você pedir EvolutionApiService em qualquer construtor ou método do seu projeto, o Laravel tenta montar ele sozinho. Mas como o construtor espera $url e $chave (duas strings), o Laravel não sabe de onde tirar esses valores — precisamos ensinar ele, e é isso que a próxima aula (Service Providers) resolve.

exemplo de uso, depois de configurado
class UsuarioController extends Controller
{
    public function __construct(protected EvolutionApiService $evolutionApi) {}

    // agora $this->evolutionApi já vem pronto, configurado, em qualquer método
}

Mão na massa

Crie o arquivo app/Services/EvolutionApiService.php exatamente como no exemplo (o método enviarMensagem pode ficar vazio por enquanto). Não precisa usar ele ainda — isso vem na Aula 11 e na Aula 23.

O que é um Service Provider

É o lugar central onde você "liga" as coisas: registra bindings (as instruções de como construir uma classe) no Service Container, configura serviços, registra eventos. Todo projeto novo já vem com um, o AppServiceProvider — foi ali mesmo que definimos o Gate na Aula 9.

Ensinando o container a montar o EvolutionApiService

app/Providers/AppServiceProvider.php
use App\Services\EvolutionApiService;

public function register(): void
{
    $this->app->singleton(EvolutionApiService::class, function ($app) {
        return new EvolutionApiService(
            url: config('services.evolution_api.url'),
            chave: config('services.evolution_api.chave'),
        );
    });
}

register() é o método certo pra isso — ele roda bem cedo, antes de qualquer outra coisa do sistema, e serve exclusivamente pra ensinar o container a construir classes. Usamos singleton (em vez de bind) porque não faz sentido criar uma nova conexão com a API do WhatsApp toda vez que alguém pede o serviço — uma instância única, reaproveitada durante toda a requisição, já resolve.

A partir daqui, qualquer lugar do seu projeto que pedir EvolutionApiService $evolutionApi num construtor recebe ele já pronto, com url e chave preenchidos — sem repetir essa configuração em lugar nenhum.

De onde vêm essas configs (preview da Aula 20)

Repare que usamos config('services.evolution_api.url') em vez de pegar direto do .env. Isso é proposital — vamos explicar exatamente por quê na Aula 20. Por enquanto, adicione isso no arquivo de config que já existe:

config/services.php
'evolution_api' => [
    'url' => env('EVOLUTION_API_URL'),
    'chave' => env('EVOLUTION_API_KEY'),
],
.env
EVOLUTION_API_URL=http://localhost:8080
EVOLUTION_API_KEY=sua-chave-aqui

Mão na massa

Adicione o bloco evolution_api em config/services.php e as duas variáveis no .env (pode deixar valores de mentira por enquanto, já que ainda não estamos chamando a API de verdade). Registre o singleton do EvolutionApiService no AppServiceProvider.

Avançado

Aulas 12–15, 17–18

O problema que isso resolve

Precisamos de algo que rode sozinho, todo dia, sem ninguém precisar clicar em nada — verificando quem faz aniversário hoje e mandando um e-mail de parabéns. Isso não pode depender de uma requisição do navegador (ninguém vai acessar o site especificamente pra "disparar" essa checagem). É exatamente pra isso que existem Jobs combinados com o agendador (scheduler) do Laravel.

O que é um Job

Job é uma classe que representa "uma tarefa que pode ser executada depois, de forma assíncrona (não bloqueando quem a disparou)" — normalmente processada por um worker, um processo separado que fica escutando a fila e executando os jobs conforme chegam.

terminal
php artisan make:job VerificarAniversariantesJob
app/Jobs/VerificarAniversariantesJob.php
namespace App\Jobs;

use App\Mail\FelizAniversarioMail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;

class VerificarAniversariantesJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function handle(): void
    {
        $aniversariantes = User::whereMonth('data_nascimento', now()->month)
            ->whereDay('data_nascimento', now()->day)
            ->get();

        foreach ($aniversariantes as $usuario) {
            Mail::to($usuario->email)->send(new FelizAniversarioMail($usuario));
        }
    }
}

implements ShouldQueue é o que diz ao Laravel "isso pode rodar em segundo plano, não precisa ser na hora". now()->month e now()->day pegam o mês e o dia de hoje. whereMonth/whereDay comparam só essa parte da data, ignorando o ano — assim pegamos todo mundo que faz aniversário hoje, não importa em que ano nasceu. O Mail::to()->send() vamos entender em detalhe na Aula 21; por enquanto, saiba que ele manda um e-mail usando o "molde" FelizAniversarioMail, que também vamos criar lá.

Agendando o Job pra rodar todo dia

No Laravel 13, o agendamento fica em routes/console.php. Isso não executa o Job agora — só ensina o Laravel a rodar ele sozinho, todo dia, num horário fixo.

routes/console.php
use App\Jobs\VerificarAniversariantesJob;
use Illuminate\Support\Facades\Schedule;

Schedule::job(new VerificarAniversariantesJob)->dailyAt('08:00');

Pra isso funcionar de verdade num servidor de produção, é preciso configurar uma única entrada de cron (o agendador nativo do Linux) rodando a cada minuto — o Laravel decide sozinho, internamente, o que precisa executar naquele momento com base no que você agendou.

crontab do servidor (produção)
* * * * * cd /caminho/do/projeto && php artisan schedule:run >> /dev/null 2>&1

Em desenvolvimento, você não precisa configurar cron nenhum pra testar — dá pra disparar o Job manualmente.

Testando sem esperar até amanhã

terminal (tinker)
php artisan tinker
dentro do tinker
(new App\Jobs\VerificarAniversariantesJob)->handle();

Isso chama o método handle() direto, sem passar pela fila — útil só pra testar rapidamente que a lógica funciona.

Mão na massa

Crie o VerificarAniversariantesJob (o FelizAniversarioMail ainda não existe — vamos criar na Aula 21, então por enquanto troque o corpo do foreach por um simples logger()->info("Feliz aniversário, {$usuario->name}!");, só pra confirmar que a busca funciona). Agende ele em routes/console.php. Pelo Tinker, atualize a data de nascimento de um usuário pra hoje (User::find(1)->update(['data_nascimento' => now()])) e rode o Job manualmente — confira em storage/logs/laravel.log se a mensagem apareceu.

O problema que isso resolve

A regra do nosso projeto é: sempre que um usuário com papel gerente (não o admin) cadastra alguém novo, três coisas precisam acontecer — o admin recebe um e-mail avisando, o novo usuário recebe um e-mail de boas-vindas, e o novo usuário recebe uma mensagem de WhatsApp. Se colocarmos essas três ações direto dentro do método store() do Controller, ele vira um método gigante, fazendo coisas demais. Event (evento) e Listener (ouvinte) resolvem isso: o Controller só anuncia "um usuário foi cadastrado", e cada Listener decide, por conta própria, se e como reagir.

Criando o Event

terminal
php artisan make:event UsuarioCadastrado
app/Events/UsuarioCadastrado.php
namespace App\Events;

use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class UsuarioCadastrado
{
    use Dispatchable, SerializesModels;

    public function __construct(
        public User $novoUsuario,
        public User $criador,
    ) {}
}

O Event é basicamente um "envelope" de dados, sem lógica nenhuma — só carrega o usuário recém-criado e quem foi o criador, pra quem for reagir ter essa informação disponível.

Disparando o Event no Controller

app/Http/Controllers/UsuarioController.php
use App\Events\UsuarioCadastrado;
use Illuminate\Support\Facades\Auth;

public function store(Request $request)
{
    Gate::authorize('cadastrar-usuarios');

    $validado = $request->validate([ /* ... como na Aula 5 */ ]);
    $validado['password'] = bcrypt($validado['password']);
    $validado['criador_id'] = Auth::id();

    $novoUsuario = User::create($validado);

    UsuarioCadastrado::dispatch($novoUsuario, Auth::user());

    return redirect('/usuarios');
}

Auth::id() retorna o id do usuário logado no momento (quem está fazendo o cadastro). UsuarioCadastrado::dispatch(...) dispara o evento — o Controller não sabe (nem precisa saber) o que vai acontecer depois disso.

Criando os três Listeners

Cada Listener cuida de uma reação. Todos vão checar se quem criou é um gerente — se foi o próprio admin que cadastrou, ninguém precisa ser notificado.

terminal
php artisan make:listener NotificarAdminListener --event=UsuarioCadastrado
php artisan make:listener EnviarBoasVindasEmailListener --event=UsuarioCadastrado
php artisan make:listener EnviarBoasVindasWhatsappListener --event=UsuarioCadastrado
app/Listeners/NotificarAdminListener.php
namespace App\Listeners;

use App\Events\UsuarioCadastrado;
use App\Mail\NovoUsuarioCriadoMail;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;

class NotificarAdminListener implements ShouldQueue
{
    public function handle(UsuarioCadastrado $event): void
    {
        if ($event->criador->papel !== 'gerente') {
            return; // só notifica quando quem cadastrou é gerente
        }

        $admins = User::where('papel', 'admin')->get();

        foreach ($admins as $admin) {
            Mail::to($admin->email)->send(new NovoUsuarioCriadoMail($event->novoUsuario, $event->criador));
        }
    }
}

O return; sozinho, sem valor nenhum, é uma forma de "sair do método imediatamente" — se a condição não bater, o resto do código simplesmente não roda.

app/Listeners/EnviarBoasVindasEmailListener.php
namespace App\Listeners;

use App\Events\UsuarioCadastrado;
use App\Mail\BoasVindasMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;

class EnviarBoasVindasEmailListener implements ShouldQueue
{
    public function handle(UsuarioCadastrado $event): void
    {
        if ($event->criador->papel !== 'gerente') {
            return;
        }

        Mail::to($event->novoUsuario->email)->send(new BoasVindasMail($event->novoUsuario));
    }
}
app/Listeners/EnviarBoasVindasWhatsappListener.php
namespace App\Listeners;

use App\Events\UsuarioCadastrado;
use App\Services\EvolutionApiService;
use Illuminate\Contracts\Queue\ShouldQueue;

class EnviarBoasVindasWhatsappListener implements ShouldQueue
{
    public function handle(UsuarioCadastrado $event): void
    {
        if ($event->criador->papel !== 'gerente') {
            return;
        }

        app(EvolutionApiService::class)->enviarMensagem(
            numero: $event->novoUsuario->telefone ?? '',
            texto: "Olá {$event->novoUsuario->name}! Seja bem-vindo(a) ao painel."
        );
    }
}

app(EvolutionApiService::class) pede o serviço direto do container (você viu esse padrão na Aula 10) — dentro de um Listener, que não é uma classe onde normalmente injetamos pelo construtor com tanta frequência, esse é o jeito mais direto de pegar o serviço já configurado.

Implementar ShouldQueue nos três Listeners faz cada um rodar em fila, separadamente — se o WhatsApp cair, isso não atrasa nem trava o e-mail de boas-vindas, e vice-versa.

Por que não usamos a classe Notification aquiO Laravel tem uma ferramenta chamada Notification (você vai ver na Aula 22) que também lida com múltiplos canais numa única classe. Optamos por Event + Listener aqui de propósito: como cada canal tem uma regra ligeiramente diferente e pode crescer separadamente no futuro, ter um Listener por canal deixa mais fácil adicionar um quarto canal (ex: Slack) sem tocar nos outros três.

Mão na massa

Crie o Event UsuarioCadastrado e os três Listeners. Ajuste o método store() pra preencher criador_id e disparar o evento (os Mails ainda não existem — pra não quebrar, troque temporariamente o corpo de cada listener por um logger()->info(...) descrevendo o que ele faria; vamos trocar pelos Mails de verdade na Aula 21). Logado como um usuário gerente (crie um pelo Tinker com 'papel' => 'gerente'), cadastre um novo usuário pelo formulário e confira em storage/logs/laravel.log se as três mensagens apareceram. Depois, logado como admin, cadastre outro usuário e confirme que nenhuma mensagem aparece — por causa do return condicional em cada Listener.

O problema que isso resolve

Imagine um pequeno painel mostrando "3 admins, 5 gerentes, 40 usuários comuns" no topo da tela de lista. Se essa contagem for feita a cada visita à página, e a página for visitada centenas de vezes por hora, você está refazendo o mesmo cálculo repetidamente à toa — o número de admins/gerentes muda raramente. Cache é guardar esse resultado num lugar de acesso rápido, pra reaproveitar em vez de recalcular.

remember() — a ferramenta certa pra isso

app/Http/Controllers/UsuarioController.php
use Illuminate\Support\Facades\Cache;

public function index()
{
    $usuarios = User::all();

    $contagemPorPapel = Cache::remember('contagem_usuarios_por_papel', now()->addMinutes(10), function () {
        return [
            'admin' => User::admins()->count(),
            'gerente' => User::gerentes()->count(),
            'comum' => User::where('papel', 'comum')->count(),
        ];
    });

    return view('usuarios.index', compact('usuarios', 'contagemPorPapel'));
}

Cache::remember('chave', tempo, function) funciona assim: "se já existe algo guardado com essa chave, devolve direto; se não existe (ou já expirou), executa a closure, guarda o resultado por 10 minutos, e devolve". Na próxima visita, dentro desses 10 minutos, o Laravel nem toca no banco — devolve o valor já calculado.

Invalidando quando um usuário novo é cadastrado

Se alguém cadastrar um usuário novo, essa contagem guardada fica desatualizada até os 10 minutos passarem. Pra corrigir na hora, invalidamos o cache assim que um novo usuário é criado — dá pra fazer isso dentro do próprio Listener que já criamos:

app/Listeners/NotificarAdminListener.php (adicionando uma linha)
public function handle(UsuarioCadastrado $event): void
{
    Cache::forget('contagem_usuarios_por_papel');

    if ($event->criador->papel !== 'gerente') {
        return;
    }

    // ... resto igual
}

Repare que colocamos o Cache::forget() antes do return condicional — porque a contagem deve ser invalidada sempre que um usuário for criado, não só quando o criador é gerente.

Mão na massa

Adicione o Cache::remember no método index e mostre a contagem no topo da view de lista (ex: <p>Admins: {{ $contagemPorPapel['admin'] }}</p>). Adicione o Cache::forget no Listener. Cadastre um usuário novo e confirme que a contagem na tela atualiza na hora, sem esperar os 10 minutos.

O problema que isso resolve

A regra do projeto diz: usuários comuns só podem ver nome e data de nascimento de cada usuário — não o e-mail, nem o papel de cada um. Se algum dia você construir uma versão em JSON dessa lista (pra consumir via JavaScript, por exemplo — o que vamos fazer na Aula 27), retornar o Model inteiro exporia campos demais. API Resource é a camada que decide exatamente o que sai no JSON, com base em quem está pedindo.

Criando o Resource

terminal
php artisan make:resource UsuarioResource
app/Http/Resources/UsuarioResource.php
namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class UsuarioResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'name' => $this->name,
            'data_nascimento' => $this->data_nascimento,
            'idade' => $this->idade,

            // só aparece se quem está vendo puder cadastrar usuários
            'email' => $this->when(
                $request->user()->can('cadastrar-usuarios'),
                $this->email
            ),
            'papel' => $this->when(
                $request->user()->can('cadastrar-usuarios'),
                $this->papel
            ),
        ];
    }
}

$request->user()->can('cadastrar-usuarios') reaproveita o mesmo Gate que criamos na Aula 9 — não escrevemos a regra de novo, só perguntamos "esse usuário passa nessa regra?". $this->when(condição, valor) só inclui o campo no JSON se a condição for verdadeira; se for falsa, o campo simplesmente não aparece (diferente de aparecer como null).

Usando o Resource num Controller de API

exemplo (endpoint JSON)
public function indexJson()
{
    $usuarios = User::all();
    return UsuarioResource::collection($usuarios);
}

UsuarioResource::collection($usuarios) aplica a mesma transformação pra cada item de uma lista, devolvendo tudo já em JSON — sem você chamar json_encode manualmente.

Mão na massa

Crie o UsuarioResource exatamente como no exemplo. Não precisa criar uma rota JSON agora (isso volta na Aula 27, quando o front-end de verdade precisar consumir esses dados) — só deixe a classe pronta.

O problema que isso resolve

Até agora, promover alguém a gerente exigia abrir o Tinker e rodar código na mão. Isso funciona, mas não é prático pro dia a dia. Um Artisan Command customizado transforma isso num comando de terminal de verdade, com nome e parâmetros — algo que qualquer pessoa da equipe pode rodar sem saber PHP.

Criando o Command

terminal
php artisan make:command PromoverUsuarioCommand
app/Console/Commands/PromoverUsuarioCommand.php
namespace App\Console\Commands;

use App\Models\User;
use Illuminate\Console\Command;

class PromoverUsuarioCommand extends Command
{
    protected $signature = 'usuarios:promover {email} {--papel=gerente}';

    protected $description = 'Promove um usuário existente pra outro papel (gerente ou admin)';

    public function handle(): int
    {
        $usuario = User::where('email', $this->argument('email'))->first();

        if (!$usuario) {
            $this->error('Nenhum usuário encontrado com esse e-mail.');
            return self::FAILURE;
        }

        $novoPapel = $this->option('papel');

        $usuario->update(['papel' => $novoPapel]);

        $this->info("{$usuario->name} agora é {$novoPapel}.");

        return self::SUCCESS;
    }
}

{email} no $signature é um argumento obrigatório (um valor que você digita na ordem, sem prefixo). {--papel=gerente} é uma opção com valor padrão — se você não informar --papel, assume gerente automaticamente. $this->error() e $this->info() imprimem mensagens coloridas no terminal (vermelho pra erro, verde/normal pra sucesso).

Rodando o comando

terminal
php artisan usuarios:promover ana@exemplo.com
php artisan usuarios:promover joao@exemplo.com --papel=admin

Mão na massa

Crie o PromoverUsuarioCommand exatamente como no exemplo. Rode php artisan usuarios:promover passando o e-mail de um dos usuários que você já cadastrou, sem passar --papel (deve virar gerente por padrão). Confirme no Tinker (User::where('email', '...')->first()->papel) que o papel mudou.

O problema: o método store() está fazendo coisa demais

Olhando o store() que construímos até aqui, ele já: valida, transforma a senha, preenche o criador, cria o usuário, e dispara o evento. Se um dia você precisar cadastrar um usuário também por um comando de terminal (tipo um script de importação em lote), teria que copiar essa lógica toda de novo. Uma Action resolve isso: uma classe com uma única responsabilidade, chamável de qualquer lugar.

Criando a Action

app/Actions/CadastrarUsuarioAction.php
namespace App\Actions;

use App\Events\UsuarioCadastrado;
use App\Models\User;

class CadastrarUsuarioAction
{
    public function executar(array $dados, User $criador): User
    {
        $dados['password'] = bcrypt($dados['password']);
        $dados['criador_id'] = $criador->id;

        $novoUsuario = User::create($dados);

        UsuarioCadastrado::dispatch($novoUsuario, $criador);

        return $novoUsuario;
    }
}

O Controller fica enxuto

app/Http/Controllers/UsuarioController.php
use App\Actions\CadastrarUsuarioAction;

public function store(Request $request, CadastrarUsuarioAction $action)
{
    Gate::authorize('cadastrar-usuarios');

    $validado = $request->validate([
        'name' => 'required|string|max:255',
        'email' => 'required|email|unique:users,email',
        'password' => 'required|string|min:6',
        'data_nascimento' => 'required|date',
        'papel' => 'required|in:admin,gerente,comum',
    ]);

    $action->executar($validado, Auth::user());

    return redirect('/usuarios');
}

Repare que CadastrarUsuarioAction $action aparece como parâmetro do método — é injeção de dependência de novo (Aula 10), o Laravel monta a Action sozinho. O Controller agora só cuida do que é responsabilidade dele: receber a requisição, validar, e devolver uma resposta. Toda a regra de negócio de "o que significa cadastrar um usuário" mora na Action, reaproveitável por qualquer outro lugar que precisar dela no futuro (o comando da Aula 17, por exemplo, poderia usar essa mesma Action em vez de fazer update() direto).

Por que só agora?Repare que, até aqui, deixamos a lógica direto no Controller de propósito — pra você primeiro ver o problema crescendo (Aula 5 → 9 → 13, o método store() foi inchando aula após aula) antes de aprender a solução. Essa é exatamente a lição da Aula 18 original: comece simples, e só introduza uma Action quando sentir a dor de verdade.

Mão na massa

Crie a CadastrarUsuarioAction movendo pra lá a lógica que hoje está no store() (transformar senha, preencher criador, criar usuário, disparar evento). Atualize o Controller pra só validar e chamar a Action. Cadastre um usuário pelo formulário de novo e confirme que continua funcionando exatamente igual — o comportamento não muda, só a organização do código.

Ferramentas do dia a dia

Aulas 19–27

O problema que isso resolve

Até aqui, criamos o primeiro usuário na mão, pelo Tinker. Isso é péssimo pra um sistema de verdade: cada vez que alguém instalar o projeto do zero (você numa máquina nova, um colega, o servidor de produção), teria que lembrar de criar o admin manualmente. Seeder resolve isso — é código versionado que popula o banco de forma automática e repetível.

Criando o Seeder do admin

terminal
php artisan make:seeder AdminSeeder
database/seeders/AdminSeeder.php
namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;

class AdminSeeder extends Seeder
{
    public function run(): void
    {
        User::create([
            'name' => 'Administrador',
            'email' => 'admin@painel.com',
            'password' => bcrypt('admin123'),
            'papel' => 'admin',
            'data_nascimento' => '1990-01-01',
        ]);
    }
}

Registrando no DatabaseSeeder

Existe um Seeder "orquestrador", chamado quando você roda só php artisan db:seed sem especificar classe:

database/seeders/DatabaseSeeder.php
namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            AdminSeeder::class,
        ]);
    }
}

Rodando

terminal
php artisan db:seed

Ou, se você quiser recomeçar do zero (apaga o banco, recria todas as tabelas, e já popula com os Seeders — útil em desenvolvimento):

terminal
php artisan migrate:fresh --seed
Cuidado com duplicarSe você rodar db:seed duas vezes sem recriar o banco, vai tentar criar dois admins com o mesmo e-mail e vai dar erro (lembra da regra unique na validação? o banco também tem essa restrição, se você configurou a coluna email como única). Uma forma mais segura, pra rodar em produção sem risco, é usar firstOrCreate em vez de create: User::firstOrCreate(['email' => 'admin@painel.com'], [...resto dos dados...]); — isso só cria se ainda não existir ninguém com aquele e-mail.

Mão na massa

Crie o AdminSeeder e registre ele no DatabaseSeeder. Rode php artisan migrate:fresh --seed pra recomeçar o banco do zero, já com o admin criado automaticamente. Faça login com admin@painel.com / admin123 e confirme que dá pra acessar a tela de cadastro.

O problema que isso resolve

Na Aula 11, usamos config('services.evolution_api.url') em vez de env('EVOLUTION_API_URL') direto no Service Provider. Existe um motivo técnico real pra isso, não é só estilo: em produção, o Laravel costuma rodar com config:cache ativado (uma otimização de performance) — e esse cache só lê os arquivos dentro de config/. Uma chamada a env() feita fora de config/ (direto no Service Provider, num Controller, em qualquer lugar) pode simplesmente devolver null quando esse cache está ativo — um bug sutil que só aparece em produção, nunca no seu ambiente local.

A regra de ouro

resumo
.env                  → guarda o VALOR (varia por ambiente: dev, produção)
config/algo.php       → guarda a CHAVE de acesso a esse valor, com env() (só aqui!)
resto do código        → usa config('algo.chave'), nunca env() direto

Já fizemos isso — revisando

config/services.php
'evolution_api' => [
    'url' => env('EVOLUTION_API_URL'),
    'chave' => env('EVOLUTION_API_KEY'),
],

Vamos aproveitar essa aula pra criar mais um arquivo de config — dessa vez pra uma regra de negócio do próprio sistema, não uma credencial externa:

config/usuarios.php
<?php

return [
    'papeis_disponiveis' => ['admin', 'gerente', 'comum'],
    'papeis_que_podem_cadastrar' => ['admin', 'gerente'],
];

Isso NÃO vem do .env (não é segredo nem varia por ambiente) — é só um lugar central pra não espalhar a lista ['admin', 'gerente', 'comum'] em vários arquivos diferentes (na validação, no Gate, no formulário). Se um dia você quiser adicionar um quarto papel, muda num lugar só.

reaproveitando na validação (Aula 5)
'papel' => 'required|in:' . implode(',', config('usuarios.papeis_disponiveis')),

implode(',', [...]) é uma função nativa do PHP que junta os itens de um array numa única string, separados pelo caractere que você escolher — aqui, transforma ['admin', 'gerente', 'comum'] em "admin,gerente,comum", exatamente o formato que a regra in: espera.

Mão na massa

Crie config/usuarios.php com os dois itens acima. Atualize a regra de validação do campo papel no store() (ou na Action, se você já fez o exercício da Aula 18) pra usar config('usuarios.papeis_disponiveis') em vez da lista escrita na mão.

O que é o sistema de Mail do Laravel

É a camada responsável por compor e enviar e-mails, com suporte a templates Blade e fila — os mesmos conceitos de View que você já sabe (Aula 3) se aplicam aqui, o corpo do e-mail também é uma view Blade normal.

Configurando o envio em desenvolvimento

.env
MAIL_MAILER=log

Com MAIL_MAILER=log, em vez de tentar enviar de verdade, o Laravel só grava o conteúdo do e-mail no arquivo storage/logs/laravel.log — perfeito pra testar sem precisar configurar um servidor de e-mail de verdade.

Criando as três Mailables

terminal
php artisan make:mail BoasVindasMail
php artisan make:mail NovoUsuarioCriadoMail
php artisan make:mail FelizAniversarioMail
app/Mail/BoasVindasMail.php
namespace App\Mail;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class BoasVindasMail extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(public User $usuario) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: 'Bem-vindo(a) ao Painel de Usuários!');
    }

    public function content(): Content
    {
        return new Content(view: 'emails.boas-vindas');
    }
}
resources/views/emails/boas-vindas.blade.php
<h1>Olá, {{ $usuario->name }}!</h1>
<p>Sua conta foi criada com sucesso no Painel de Usuários.</p>
<p>Seu e-mail de acesso é: {{ $usuario->email }}</p>

Dentro da view, $usuario está disponível automaticamente — o Laravel pega qualquer propriedade pública do construtor da Mailable e entrega pra view, sem você passar manualmente.

As outras duas seguem o mesmo padrão:

app/Mail/NovoUsuarioCriadoMail.php
class NovoUsuarioCriadoMail extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(public User $novoUsuario, public User $criador) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: "Novo usuário cadastrado por {$this->criador->name}");
    }

    public function content(): Content
    {
        return new Content(view: 'emails.novo-usuario-criado');
    }
}
app/Mail/FelizAniversarioMail.php
class FelizAniversarioMail extends Mailable
{
    use Queueable, SerializesModels;

    public function __construct(public User $usuario) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: 'Feliz Aniversário! 🎉');
    }

    public function content(): Content
    {
        return new Content(view: 'emails.feliz-aniversario');
    }
}

Ligando de volta nos Listeners e no Job

Agora sim dá pra trocar os logger()->info(...) que deixamos temporariamente nas Aulas 12 e 13 pelos e-mails de verdade:

app/Listeners/EnviarBoasVindasEmailListener.php
Mail::to($event->novoUsuario->email)->send(new BoasVindasMail($event->novoUsuario));
app/Jobs/VerificarAniversariantesJob.php
Mail::to($usuario->email)->send(new FelizAniversarioMail($usuario));

Mão na massa

Crie as três Mailables e suas views. Substitua os logger()->info(...) temporários (das Aulas 12 e 13) pelos envios de e-mail de verdade. Cadastre um usuário logado como gerente e confira, em storage/logs/laravel.log, se os dois e-mails (admin + novo usuário) aparecem registrados. Rode o Job de aniversário manualmente de novo e confira o terceiro.

O que são Notifications e por que existem, se já temos Mail

Notification é uma camada mais abstrata que Mail: em vez de "vou mandar um e-mail", você pensa "vou notificar alguém sobre algo" — e escolhe, na mesma classe, por quais canais isso sai: e-mail, banco de dados (o "sino" de notificação que você vê em painéis administrativos), entre outros. Vamos usar essa ferramenta pra dar ao admin, além do e-mail que já criamos na Aula 21, também um aviso visual dentro do próprio sistema.

Criando a Notification

terminal
php artisan make:notification NovoUsuarioNotification
php artisan notifications:table
php artisan migrate

O comando notifications:table cria a migration da tabela onde as notificações de banco de dados ficam guardadas — é esse comando que faz o canal database ter onde salvar.

app/Notifications/NovoUsuarioNotification.php
namespace App\Notifications;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Notifications\Messages\MailMessage;

class NovoUsuarioNotification extends Notification
{
    use Queueable;

    public function __construct(public User $novoUsuario, public User $criador) {}

    public function via(object $notifiable): array
    {
        return ['database'];
    }

    public function toArray(object $notifiable): array
    {
        return [
            'mensagem' => "{$this->criador->name} cadastrou {$this->novoUsuario->name}",
        ];
    }
}

via() define os canais — aqui só 'database', já que o e-mail nós mantemos vindo do Listener da Aula 13, pra não duplicar o envio. toArray() define o que fica guardado na tabela de notificações.

Disparando junto com o resto

app/Listeners/NotificarAdminListener.php
use App\Notifications\NovoUsuarioNotification;

public function handle(UsuarioCadastrado $event): void
{
    Cache::forget('contagem_usuarios_por_papel');

    if ($event->criador->papel !== 'gerente') {
        return;
    }

    $admins = User::where('papel', 'admin')->get();

    foreach ($admins as $admin) {
        Mail::to($admin->email)->send(new NovoUsuarioCriadoMail($event->novoUsuario, $event->criador));
        $admin->notify(new NovoUsuarioNotification($event->novoUsuario, $event->criador));
    }
}

$admin->notify(...) funciona porque o model User já vem, por padrão, com a trait Notifiable — é ela quem dá esse método notify() pra qualquer usuário.

Mostrando o "sino" na tela

resources/views/layouts/app.blade.php
@auth
    <span>🔔 {{ auth()->user()->unreadNotifications->count() }}</span>
    @foreach (auth()->user()->unreadNotifications as $notificacao)
        <p>{{ $notificacao->data['mensagem'] }}</p>
    @endforeach
@endauth

unreadNotifications é uma propriedade que já vem pronta, também da trait Notifiable — retorna só as notificações que ainda não foram marcadas como lidas.

Mão na massa

Crie a NovoUsuarioNotification e rode a migration do canal database. Adicione o $admin->notify(...) no Listener, junto do e-mail. Adicione o sininho no layout. Cadastre outro usuário como gerente e confira que, ao logar como admin, o número ao lado do 🔔 aumenta.

O que é

O HTTP Client do Laravel permite fazer requisições a APIs externas com uma sintaxe fluente (métodos encadeados, fáceis de ler), sem precisar configurar nada complicado. É exatamente o que falta pro nosso EvolutionApiService, criado vazio lá na Aula 10, mandar mensagem de verdade.

Implementando o envio

app/Services/EvolutionApiService.php
namespace App\Services;

use Illuminate\Support\Facades\Http;

class EvolutionApiService
{
    public function __construct(
        protected string $url,
        protected string $chave,
    ) {}

    public function enviarMensagem(string $numero, string $texto): void
    {
        if (empty($numero)) {
            return; // usuário sem telefone cadastrado — não tem pra onde mandar
        }

        $response = Http::withHeaders(['apikey' => $this->chave])
            ->timeout(10)
            ->retry(2, 300) // tenta mais uma vez se a primeira falhar
            ->post("{$this->url}/message/sendText", [
                'number' => $numero,
                'text' => $texto,
            ]);

        if ($response->failed()) {
            logger()->error('Falha ao enviar WhatsApp', [
                'numero' => $numero,
                'status' => $response->status(),
            ]);
        }
    }
}

empty($numero) é uma função nativa do PHP que verifica se uma variável está vazia (string vazia, null, zero, etc.) — uma proteção simples pra não tentar mandar mensagem pra ninguém. Http::withHeaders([...]) monta os cabeçalhos da requisição (aqui, a chave de autenticação que a evolution-api exige). ->retry(2, 300) tenta de novo até 2 vezes, esperando 300 milissegundos entre tentativas, se a primeira falhar — útil porque APIs externas às vezes engasgam por um instante. Importante: por padrão, o HTTP Client do Laravel não lança um erro automaticamente quando a API responde com falha — por isso checamos $response->failed() manualmente.

Adicionando telefone ao cadastro

Pra isso funcionar de verdade, precisamos de um campo telefone no usuário — vamos adicionar rapidinho, do mesmo jeito que já fizemos na Aula 4:

terminal
php artisan make:migration add_telefone_to_users_table --table=users
migration
Schema::table('users', function (Blueprint $table) {
    $table->string('telefone')->nullable();
});

Não esqueça de adicionar 'telefone' no $fillable do model, no formulário de cadastro, e na regra de validação ('telefone' => 'nullable|string').

Mão na massa

Complete o EvolutionApiService como no exemplo. Adicione a coluna telefone, o campo no formulário e no $fillable. Como você provavelmente não tem uma instância real da evolution-api rodando, use Http::fake(['*' => Http::response(['status' => 'ok'], 200)]) no Tinker antes de testar, pra simular uma resposta de sucesso sem chamar nada de verdade.

O sistema de Storage do Laravel

O Storage é a camada de abstração do Laravel pra ler/gravar arquivos, funcionando igual não importa se o arquivo está salvo localmente ou na nuvem. Vamos usar isso pra deixar cada usuário com uma foto de perfil.

terminal
php artisan storage:link
php artisan make:migration add_foto_to_users_table --table=users
migration
Schema::table('users', function (Blueprint $table) {
    $table->string('foto')->nullable();
});

Ajustando o formulário

resources/views/usuarios/criar.blade.php
<form method="POST" action="/usuarios" enctype="multipart/form-data">
    @csrf
    {{-- ...campos existentes... --}}
    <label>Foto de perfil</label>
    <input type="file" name="foto">
    <button type="submit">Cadastrar</button>
</form>

enctype="multipart/form-data" é obrigatório sempre que um formulário HTML precisa enviar arquivo — sem isso, o navegador nem tenta mandar o conteúdo do arquivo, só o nome dele.

Salvando o arquivo no Controller/Action

app/Actions/CadastrarUsuarioAction.php
public function executar(array $dados, User $criador, ?\Illuminate\Http\UploadedFile $foto = null): User
{
    $dados['password'] = bcrypt($dados['password']);
    $dados['criador_id'] = $criador->id;

    if ($foto) {
        $dados['foto'] = $foto->store('fotos-usuarios', 'public');
    }

    $novoUsuario = User::create($dados);

    UsuarioCadastrado::dispatch($novoUsuario, $criador);

    return $novoUsuario;
}

?\Illuminate\Http\UploadedFile $foto = null declara um parâmetro opcional (o ? antes do tipo e o = null depois do nome dizem "isso pode não vir preenchido"). $foto->store('fotos-usuarios', 'public') gera um nome de arquivo aleatório, salva dentro de storage/app/public/fotos-usuarios/, e devolve o caminho relativo pra guardar no banco.

Controller — passando o arquivo pra Action
$action->executar($validado, Auth::user(), $request->file('foto'));

Exibindo a foto

Blade
@if ($usuario->foto)
    <img src="{{ Storage::url($usuario->foto) }}" width="40">
@endif

Mão na massa

Adicione a coluna foto, o campo de upload no formulário, e ajuste a Action (ou o Controller, se você não fez o exercício da Aula 18) pra salvar o arquivo. Mostre a foto na lista de usuários, quando existir. Cadastre um usuário com foto e confirme que ela aparece.

O que é

DB é o facade (uma forma estática de acessar uma classe do container de serviços) que dá acesso direto à camada de conexão com o banco — a mesma sobre a qual o Eloquent é construído. Usamos quando precisamos de algo que o Eloquent não expressa tão bem, como um agrupamento estatístico.

Quantos usuários nascem em cada mês

exemplo
use Illuminate\Support\Facades\DB;

$porMes = DB::table('users')
    ->selectRaw('MONTH(data_nascimento) as mes, COUNT(*) as total')
    ->groupBy('mes')
    ->orderBy('mes')
    ->get();

selectRaw() permite escrever uma expressão SQL crua quando o Query Builder não tem um método pronto pra ela — aqui, MONTH(data_nascimento) extrai só o número do mês de cada data. groupBy('mes') agrupa as linhas por esse mês, e COUNT(*) conta quantas linhas caem em cada grupo. O resultado é uma lista tipo "mês 3 → 5 pessoas, mês 7 → 2 pessoas".

Transação — protegendo o cadastro em massa

Se um dia você importar vários usuários de uma planilha de uma vez, quer que ou todos sejam criados, ou nenhum — nunca "só metade". É pra isso que serve DB::transaction():

exemplo
DB::transaction(function () use ($listaDeUsuarios) {
    foreach ($listaDeUsuarios as $dados) {
        User::create($dados);
    }
    // se qualquer create() falhar no meio, TUDO é desfeito automaticamente
});

Mão na massa

Adicione a consulta porMes num novo método do Controller (ex: relatorioAniversarios()) e crie uma rota/view simples mostrando o resultado numa lista (ex: "Março: 2 pessoas").

O problema que isso resolve

O sino que construímos na Aula 22 só atualiza quando a página recarrega. Se o admin estiver com a tela aberta e um gerente cadastrar alguém em outra aba, o número não muda sozinho. Broadcasting transmite eventos do servidor pro navegador em tempo real, via WebSocket (uma conexão que fica aberta entre navegador e servidor).

Tornando o evento transmissível

terminal
php artisan install:broadcasting
php artisan reverb:start
app/Events/UsuarioCadastrado.php
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class UsuarioCadastrado implements ShouldBroadcast
{
    use Dispatchable, SerializesModels;

    public function __construct(public User $novoUsuario, public User $criador) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel('admins')];
    }

    public function broadcastWith(): array
    {
        return ['mensagem' => "{$this->criador->name} cadastrou {$this->novoUsuario->name}"];
    }
}

Só adicionar implements ShouldBroadcast não muda nada do que já fizemos — o Event continua dando origem aos mesmos três Listeners de sempre. Ele só ganha um destino a mais: o navegador.

routes/channels.php
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('admins', function ($user) {
    return $user->papel === 'admin';
});

Essa closure decide quem pode "ouvir" o canal — só usuários com papel admin recebem essas transmissões.

Escutando no navegador

resources/js/app.js
Echo.private('admins')
    .listen('UsuarioCadastrado', (e) => {
        alert(e.mensagem); // versão simples — dá pra trocar por algo mais bonito depois
    });

Mão na massa

Adicione ShouldBroadcast ao Event, configure o canal em routes/channels.php, e escute ele no JavaScript. Abra duas abas logadas como admin — cadastre um usuário como gerente numa aba e veja o alerta aparecer sozinho na outra, sem recarregar.

Esse projeto foi pensado pra você dominar bem o "tripé" clássico do desenvolvimento web com Laravel — PHP, Blade/HTML e CSS (com a ajuda do Tailwind pra estilo) — antes de qualquer coisa em JavaScript. Por isso, a lista de usuários continua sendo uma tabela HTML normal, renderizada pelo servidor com @foreach (do jeito que você já sabe desde a Aula 3), só que agora com classes do Tailwind pra ficar com cara de sistema de verdade. Sem biblioteca de JavaScript nenhuma.

O que é Tailwind — um jeito diferente de pensar CSS

Tailwind é um framework de CSS, mas funciona de um jeito bem diferente de coisas como Bootstrap. Em vez de te dar classes já "prontas" com nome de componente (tipo card, btn-primary, navbar — onde uma classe só já resolve um monte de estilo escondido nela), o Tailwind te dá centenas de classes bem pequenas e diretas, cada uma fazendo uma única coisa (utility-first CSS, "CSS orientado a utilitários") — bg-white só põe fundo branco, p-4 só adiciona espaçamento interno, rounded-lg só arredonda os cantos. Você monta o visual combinando várias dessas classes direto na tag HTML.

Isso significa que sua tag vai acumular bastante classe (o que estranha no começo!), mas em troca você tem controle total sem precisar "brigar" com CSS escondido em outro arquivo tentando descobrir de onde veio aquele estilo — está tudo ali, na própria linha do HTML.

Adicionando o Tailwind ao layout

Pra estudar, o jeito mais simples é carregar o Tailwind via CDN (um link que carrega o arquivo direto de um servidor público, sem precisar instalar nada nem configurar build). Esse modo "Play CDN" gera as classes sob demanda, direto no navegador — ótimo pra aprender e prototipar; projetos maiores em produção normalmente compilam o Tailwind com o Vite (a mesma ferramenta que citamos lá na Aula 26), mas isso foge do nosso escopo aqui.

resources/views/layouts/app.blade.php
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>@yield('titulo') — Painel de Usuários</title>
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50">
    <nav class="bg-gray-900 text-white px-6 py-4">
        <span class="font-bold text-lg">Painel de Usuários</span>
    </nav>

    <main class="max-w-4xl mx-auto mt-6 px-4">
        @yield('conteudo')
    </main>
</body>
</html>

A partir daqui, qualquer classe do Tailwind que você usar em qualquer view (inclusive nas de aulas anteriores, se quiser voltar e arrumar) já vai funcionar, sem mais nenhuma configuração.

O vocabulário básico — as categorias que você mais vai usar

Diferente do Bootstrap (que tem poucas cores "de tema", tipo primary/secondary), o Tailwind te dá uma escala numérica de 50 até 900 pra cada cor — quanto maior o número, mais escuro o tom (gray-50 é quase branco, gray-900 é quase preto). Essa tabela cobre o essencial pra você já conseguir ler o resto do código dessa aula:

CategoriaExemplosO que faz
Espaçamentop-4, px-4, py-2, mt-6p=padding, m=margin; x=horizontal, y=vertical; o número segue uma escala fixa (4 = 1rem)
Coresbg-gray-900, text-whitebg=fundo, text=cor do texto, border=cor da borda
Tipografiatext-sm, text-lg, font-boldTamanho e peso (grossura) da fonte
Layoutflex, w-full, max-w-4xl, mx-autoComo os elementos se organizam e ocupam espaço na tela (mx-auto centraliza horizontalmente)
Bordas e cantosrounded-lg, rounded-full, divide-yCantos arredondados e linhas divisórias automáticas entre elementos filhos
Estadoshover:bg-gray-100, even:bg-gray-50A classe depois dos dois-pontos só se aplica quando aquela condição acontece (mouse em cima, item de posição par, campo focado)

A tabela, sem nenhuma linha de JavaScript

Trocamos a lista <ul>/<li> da Aula 3 por uma <table> de verdade. A regra de "usuário comum só vê nome e nascimento" continua sendo resolvida do mesmo jeito que já vimos na Aula 9 — com @can, checando o Gate direto no Blade, sem precisar de Controller nem endpoint novo:

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

@section('titulo', 'Usuários')

@section('conteudo')
    <h1 class="text-2xl font-bold text-gray-800 mb-6">Usuários cadastrados</h1>

    <table class="w-full border-collapse bg-white shadow-sm rounded-lg overflow-hidden">
        <thead class="bg-gray-100 text-left text-sm font-semibold text-gray-600">
            <tr>
                <th class="px-4 py-3">Nome</th>
                <th class="px-4 py-3">Nascimento</th>
                @can('cadastrar-usuarios')
                    <th class="px-4 py-3">E-mail</th>
                    <th class="px-4 py-3">Papel</th>
                @endcan
            </tr>
        </thead>
        <tbody class="divide-y divide-gray-100">
            @foreach ($usuarios as $usuario)
                <tr class="even:bg-gray-50 hover:bg-gray-100">
                    <td class="px-4 py-3">{{ $usuario->name }}</td>
                    <td class="px-4 py-3">{{ $usuario->data_nascimento }}</td>
                    @can('cadastrar-usuarios')
                        <td class="px-4 py-3">{{ $usuario->email }}</td>
                        <td class="px-4 py-3">
                            <span class="inline-block bg-indigo-100 text-indigo-700 text-xs font-medium px-2.5 py-1 rounded-full">
                                {{ $usuario->papel }}
                            </span>
                        </td>
                    @endcan
                </tr>
            @endforeach
        </tbody>
    </table>
@endsection

Repare que, diferente do Bootstrap (que te dá table-striped e table-hover já prontos), no Tailwind você monta esses efeitos você mesmo, combinando utilitários: divide-y divide-gray-100 desenha uma linha fina entre cada <tr> automaticamente (sem precisar bordar célula por célula), even:bg-gray-50 pinta só as linhas de posição par (o efeito "zebrado"), e hover:bg-gray-100 destaca a linha quando o mouse passa em cima. O "selinho" colorido do papel também é construído do zero: rounded-full deixa os cantos totalmente arredondados (formato de pílula), e o par bg-indigo-100/text-indigo-700 usa um tom bem claro de fundo com um tom bem mais escuro da mesma cor no texto, pra manter contraste e legibilidade.

Repare também que quem decide se a coluna de e-mail/papel aparece continua sendo o @can, avaliado no servidor antes do HTML ser enviado — um usuário comum nem recebe esse HTML no navegador, diferente de "esconder com CSS", que poderia ser burlado inspecionando a página.

Por que não usamos JavaScript aquiDá pra construir sistemas administrativos inteiros e funcionais só com Blade renderizando HTML no servidor — é o jeito mais simples de manter, e é exatamente esse o foco desse projeto: Laravel, PHP, HTML e CSS bem dominados primeiro. JavaScript entra depois, quando você sentir uma necessidade real de interatividade (uma tela que atualiza sem recarregar, por exemplo) — e não porque "é assim que se faz".

Mão na massa

Adicione o script do Tailwind no layout. Troque a lista <ul> da view usuarios/index.blade.php pela tabela acima. Teste logado como comum (deve ver só nome e nascimento) e depois como admin (deve ver todas as colunas). Se quiser, aproveite e estilize o formulário de cadastro (Aula 3/5) com Tailwind — nos inputs, algo como class="border border-gray-300 rounded px-3 py-2 w-full", e no botão, class="bg-indigo-600 text-white px-4 py-2 rounded hover:bg-indigo-700" — não muda nenhuma lógica, só o visual.

Essa aula é só uma referência, pra você conhecer. Nada aqui faz parte do painel de usuários que você construiu — seu projeto continua 100% com formulários tradicionais (aqueles que recarregam a página inteira no POST, como fizemos desde a Aula 5). Mas é bem comum, em outros projetos Laravel, ver formulários sendo enviados via AJAX (uma requisição feita pelo JavaScript, em segundo plano, sem recarregar a página) — vale saber como isso se pareceria.

O token CSRF, agora vindo por um header

Lembra do @csrf que todo formulário Blade usa (Aula 5)? Uma requisição AJAX não passa por um <form> de verdade, então esse token precisa ser mandado manualmente, no cabeçalho da requisição:

layout — meta tag
<meta name="csrf-token" content="{{ csrf_token() }}">
exemplo de referência
$.ajaxSetup({
    headers: { 'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content') }
});

Enviando o formulário sem recarregar a página

exemplo de referência
$('#form-cadastro').on('submit', function (e) {
    e.preventDefault(); // impede o comportamento padrão (recarregar a página)

    $.ajax({
        url: '/usuarios',
        method: 'POST',
        data: $(this).serialize(), // empacota todos os campos do form automaticamente
        success: function (resposta) {
            alert('Usuário cadastrado com sucesso!');
        },
        error: function (xhr) {
            if (xhr.status === 422) {
                console.log(xhr.responseJSON.errors); // erros de validação, um por campo
            }
        }
    });
});

.serialize() pega todos os campos do <form> e monta o corpo da requisição sozinho. Pra esse exemplo funcionar de ponta a ponta, o método store() do Controller também precisaria devolver uma resposta em JSON (response()->json([...])) em vez do redirect() que usamos no projeto real — é mais uma peça que muda quando se troca de abordagem, e é justamente por isso que decidimos não misturar as duas no mesmo projeto.

Referência Rápida

Aula 28 · resumo

Esse é o "cheat sheet" — pra quando você esquecer se era singular ou plural, camelCase ou snake_case. Uma distinção importa em toda essa lista: algumas convenções são mecânicas (o framework literalmente usa o nome pra montar a query ou resolver o arquivo — fugir delas quebra o funcionamento, a menos que você configure manualmente); outras são só estilo de comunidade (o Laravel funciona do mesmo jeito não importa o nome, mas seguir a convenção deixa o código previsível pra qualquer dev que abrir o projeto).

A regra-mãe: camelCase no PHP, snake_case no banco

Em código PHP (métodos, variáveis), o padrão é camelCase ($dataNascimento, calcularIdade()). Em banco de dados (colunas, tabelas), o padrão é snake_case (data_nascimento, criador_id). O Eloquent converte automaticamente entre os dois nos accessors — é por isso que um método idade() vira $usuario->idade na hora de usar.

Models e Tabelas (mecânico)

O quêConvençãoExemplo do nosso projeto
Nome do ModelSingular, PascalCaseUser
Nome da tabelaPlural, snake_caseusers
Chave primáriaid, auto-incremento$table->id();
Fugir da convençãoSobrescrever explicitamenteprotected $table = 'nome';

Cuidado com plurais irregulares do português (papelpapéis, não papels) — a pluralização automática do Laravel segue regras de inglês.

Chaves Estrangeiras (mecânico)

O quêConvençãoExemplo
Chave estrangeira{model_singular}_idcriador_id (aponta pra outro User)
Tabela pivot (N:N)Os dois models no singular, ordem alfabéticacategoria_produto

Relacionamentos (nome do método) (estilo de comunidade)

TipoConvençãoExemplo do projeto
hasOne / belongsTo (1-para-1)Singular, camelCasecriador()
hasMany / belongsToMany (vários)Plural, camelCaseusuariosCriados()

Controllers, Rotas e Migrations (estilo de comunidade)

O quêConvençãoExemplo
Nome do ControllerPascalCase + sufixo ControllerUsuarioController
Nome de rota (dot notation)Gerado por Route::resource()usuarios.index
Arquivo de migrationVerbo + tabela, snake_caseadd_papel_to_users_table

Classes auxiliares (Policy é mecânico; o resto é estilo)

O quêConvençãoExemplo
Policy{Model}Policy, em app/Policies/ — descoberta automáticaUserPolicy
Form Request{Ação}{Model}RequestStoreUsuarioRequest
API Resource{Model}ResourceUsuarioResource
JobVerbo + assunto, PascalCaseVerificarAniversariantesJob
EventFato no particípio passadoUsuarioCadastrado
ListenerAção + sufixo ListenerEnviarBoasVindasEmailListener
MailableAssunto + sufixo MailBoasVindasMail
ActionVerbo + assunto + sufixo ActionCadastrarUsuarioAction
Scope localMétodo scopeNome(), chamado sem o prefixoscopeGerentes()User::gerentes()
AccessorMétodo camelCase, acesso snake_caseidade()$u->idade

Blade (mecânico)

O quêConvençãoExemplo
ViewDot notation aponta pra subpastaview('usuarios.index')usuarios/index.blade.php
Componente (tag)kebab-case, prefixo x-<x-alerta-erro>

Config e Ambiente (mecânico)

O quêConvençãoExemplo
Variável de ambienteSCREAMING_SNAKE_CASEEVOLUTION_API_URL
Chave de configsnake_case, dot notationconfig('usuarios.papeis_disponiveis')

Regra prática pra decidir se algo é mecânico ou estilo: se o Laravel precisa adivinhar um nome pra fazer alguma coisa funcionar sozinha, é mecânico. Se é só o nome de uma classe que você mesmo vai referenciar em todo lugar, é estilo — funciona do jeito que você chamar, mas seguir o padrão poupa confusão.

O sistema completo, de relance

Se você fez todos os exercícios, seu projeto agora tem: rotas e Controller de usuários protegidos por autenticação e Gate; um model User estendido com papel, data de nascimento, foto e telefone; um relacionamento de auto-referência (quem criou quem); scopes e accessor de idade; um Event disparando três Listeners em fila (e-mail pro admin, e-mail e WhatsApp pro novo usuário); uma Notification com sino em tempo real via Broadcasting; um Job agendado verificando aniversariantes todo dia; um Seeder criando o admin original; e uma listagem em tabela HTML com Tailwind respeitando quem pode ver o quê. Esse é o "mini-ERP de usuários" completo — e cada peça dele você escreveu com a própria mão, aula por aula, sem depender de JavaScript nenhum.

nenhuma aula encontrada