← Voltar à Página Inicial

Laravel


Dia 1 - MVC, Command e Dusk

Configurando ambiente

Iremos usar o docker para fazer a instalação. O Composer é um gerenciador de dependências para PHP. Ele permite instalar, atualizar e gerenciar bibliotecas e pacotes de forma simples, garantindo que um projeto tenha todas as dependências necessárias. No Laravel, o Composer é usado para instalar o framework e suas bibliotecas.

docker run --rm -it \
  -v $(pwd):/app \
  -u $(id -u):$(id -g) \
  composer:latest \
  composer create-project laravel/laravel cursolaravel

Dockerfile pronto para usar no contexto USP:

FROM uspdev/uspdev-php-apache:latest

RUN sed -i 's|/var/www/html|/var/www/html/public|' \
    /etc/apache2/sites-available/000-default.conf

USER www-data

COPY --chown=www-data . .

RUN composer install \
    --no-dev \
    --optimize-autoloader \
    --no-interaction

CMD ["apache2-foreground"]

docker-compose.yml pronto para usar no contexto USP:

services:
  cursolaravel:
    build: .
    image: cursolaravel:latest
    container_name: cursolaravel
    ports:
      - "8000:80"
    depends_on:
      - mariadb
    networks:
      - cursolaravel-network
    volumes:
      - ./:/var/www/html
    environment:
      HOME: /tmp
    user: "${UID:-1000}:${GID:-1000}"

  mariadb:
    image: mariadb:11
    container_name: cursolaravel_mariadb
    restart: always
    environment:
      MYSQL_DATABASE: cursolaravel
      MYSQL_USER: cursolaravel
      MYSQL_PASSWORD: cursolaravel
      MYSQL_ROOT_PASSWORD: cursolaravel
    volumes:
      - mariadb_data:/var/lib/mysql
    networks:
      - cursolaravel-network

  phpmyadmin:
    image: phpmyadmin:latest
    container_name: cursolaravel_phpmyadmin
    restart: always
    depends_on:
      - mariadb
    ports:
      - "8081:80"
    environment:
      PMA_HOST: mariadb
      PMA_PORT: 3306
      PMA_USER: root
      PMA_PASSWORD: cursolaravel
    networks:
      - cursolaravel-network

  selenium:
    image: selenium/standalone-chrome
    container_name: cursolaravel_selenium
    ports:
      - "7900:7900"
    networks:
      - cursolaravel-network
    shm_size: 2gb

  # No /etc/hosts do seu computador colocar a linha `127.0.0.1 auth.local` 
  # particularidades do protocolo oAuth1 usado na USP
  senhaunica-faker:
    image: uspdev/senhaunica-faker
    container_name: cursolaravel_senhaunica-faker
    ports:
      - "3141:3141"
    environment:
          - APP_URL=http://auth.local:3141
    networks:
      cursolaravel-network:
        aliases:
          - auth.local

  mailpit:
    image: axllent/mailpit:latest
    container_name: cursolaravel_mailpit
    ports:
      - "1025:1025"   # SMTP
      - "8025:8025"   # Interface Web
    networks:
      - cursolaravel-network

networks:
  cursolaravel-network:

volumes:
  mariadb_data:

Baixando ambos:

curl -L https://fflch.github.io/assets/laravel/Dockerfile -o Dockerfile
curl -L https://fflch.github.io/assets/laravel/docker-compose.yml -o docker-compose.yml

Criando a imagem e subindo ambiente:

docker compose up --build

Acessar o laravel criado: http://127.0.0.1:8000/

Acessar phpmyadmin criado: http://127.0.0.1:8081/

Acessar servidor de autenticação USP: http://auth.local:3141

Acessar servidor de email: http://localhost:8025/

Acessar http://localhost:7900/ com senha secret para assistir os testes rodando.

No arquivo .env vamos trocar para mariadb:

DB_CONNECTION=mariadb
DB_HOST=mariadb
DB_PORT=3306
DB_DATABASE=cursolaravel
DB_USERNAME=cursolaravel
DB_PASSWORD=cursolaravel

Recriando tabelas no banco de dados:

docker exec -it cursolaravel php artisan migrate

MVC

Uma rota é a forma como o framework define e gerencia URLs para acessar diferentes partes da aplicação. As rotas são configuradas no arquivo routes/web.php (para páginas web) ou routes/api.php (para APIs) e determinam qual código será executado quando um usuário acessa uma URL específica. Exemplo:

Route::get('/rota-sem-controller', function () {
    echo "Uma rota sem controller, not good!";
});

O controller é uma classe responsável por organizar a lógica da aplicação, separando as regras de negócio das rotas. Em vez de definir toda a lógica diretamente nas rotas, os controllers agrupam funcionalidades relacionadas, tornando o código mais limpo e modular.
A convenção de nomenclatura para controllers segue o padrão PascalCase, onde o nome deve ser descritivo, no singular e sempre terminar com "Controller", como ProdutoController ou UsuarioController.

docker exec -it cursolaravel php artisan make:controller MeuPrimeiroController

Método do controller:

public function index(){
    return 'Uma rota com controller, Great!';
}

Mesma ideia, mas com controller:

use App\Http\Controllers\MeuPrimeiroController;
Route::get('/rota-com-controller', [MeuPrimeiroController::class,'index']);

Com essa ideia, vamos criar um sistema de cadastro de livros:

docker exec -it cursolaravel php artisan make:controller LivroController

A seguir criamos a rota livros e a apontamos para o controller LivroController, importando anteriormente o namespace App\Http\Controllers\LivroController.

use App\Http\Controllers\LivroController;
Route::get('/livros', [LivroController::class,'index']);

A camada View é responsável por exibir a interface da aplicação, separando a lógica de apresentação da lógica de negócio (controller). Ela utiliza o Blade, uma linguagem de templates que permite criar páginas dinâmicas de forma eficiente. As views ficam armazenadas na pasta resources/views e podem ser retornadas a partir de um controller usando return view('nome_da_view').

mkdir resources/views/livros
touch resources/views/livros/index.blade.php

No controller:

public function index(){
  return view('livros.index');
}

Conteúdo mínimo de index.blade.php:

<!DOCTYPE html>
<html>
    <head>
        <title>Livros</title>
    </head>
    <body>
        Memórias de um Sargento de Milícias<br>
        O Primo Basílio<br>
        Memórias Póstumas de Brás Cubas<br>
        A Hora da Estrela<br>
    </body>
</html>

Model

O Model é uma representação de uma tabela no banco de dados e é responsável pela interação com os dados dessa tabela.

Criando o model chamado Livro:

docker exec -it cursolaravel php artisan make:model Livro -m

As migrations são uma forma de versionar e gerenciar o esquema do banco de dados, permitindo criar, alterar e remover tabelas de forma controlada e rastreável.

Cada migration é uma classe PHP que define as operações a serem realizadas no banco de dados. As migrations são armazenadas na pasta database/migrations. Vamos colocar três colunas para o model Livro: titulo, autor e ano.

$table->string('titulo');
$table->string('autor');
$table->integer('ano');

Depois da modificação na migration, aplicá-la no banco de dados: docker exec -it cursolaravel php artisan migrate.

Tinker

O comando docker exec -it cursolaravel php artisan tinker nos permite digitar comandos PHP e ver imediatamente o resultado, como se estivesse dentro da sua aplicação Laravel, ou seja, executamos comandos PHP diretamente dentro do contexto da aplicação, de forma prática e rápida.

Usando o tinker, vamos cadastrar dois livros:

$livro = new \App\Models\Livro;
$livro->titulo = "Memórias de um Sargento de Milícias";
$livro->autor = "Manuel Antônio de Almeida";
$livro->ano = 1853;
$livro->save();

$livro = new \App\Models\Livro;
$livro->titulo = "O Primo Basílio";
$livro->autor = "Eça de Queiroz";
$livro->ano = 1878;
$livro->save();

Na view da index podemos listar os livros cadastrados:

use App\Models\Livro;

public function index(){
  return view('livros.index',[
    'livros' => Livro::all()
  ]);
}

No blade index.blade.php, listamos os livros:

<h1>Listagem de Livros</h1>
<ul>
    @foreach($livros as $livro)
        <li>{{ $livro->titulo }}, por <i>{{ $livro->autor }}</i> em {{ $livro->ano }}</li>
    @endforeach
</ul>

Busca

Inserindo um campo para busca simples no blade:

<form>
    <input type="text" name="search" value="{{ request('search') }}">
    <button type="submit">Pesquisar</button>
</form>

E no controller temos que tratar a busca:

public function index(Request $request){
    if($request->has('search')){
        $livros = Livro::where('titulo','like','%'.$request->search.'%')->get();
    } else {
        $livros = Livro::all();
    }

    return view('livros.index',[
        'livros' => $livros
    ]);
}

Command

Por fim, podemos criar um comando no artisan que automatiza o cadastro de livros a partir de alguma lógica que podemos desenvolver.

docker exec -it cursolaravel php artisan make:command ImportaLivros

O comando acima criará o arquivo app/Console/Commands/ImportaLivros.php, vamos implementar a inserção de um livro via linha de comando:

public function handle()
{
    $livro = new \App\Models\Livro;
    $livro->titulo = "A Hora da Estrela";
    $livro->autor = "Clarice Lispector";
    $livro->ano = 1977;
    $livro->save();
}

Ao rodarmos no terminal o comando docker exec -it cursolaravel php artisan app:importa-livros o livro da Clarice será cadastrado. Essa é um implementação simples (e inútil, pois o mesmo livro é sempre cadastrado repetidamente), mas a ideia é que qualquer lógica pode ser implementada no handle() para cadastro de muitos livros a partir de uma fonte externa, como, por exemplo, uma lista de livros oriunda de um arquivo csv.

Dusk

Os testes com Laravel Dusk no nosso contexto tem dois propósitos:

  1. Testar funcionalidades reais do sistema, simulando a interação de um usuário no navegador.
  2. Servir como documentação funcional, demonstrando como as principais funcionalidades do sistema devem se comportar.
docker exec -it cursolaravel composer require --dev laravel/dusk
docker exec -it cursolaravel php artisan dusk:install
docker exec -it cursolaravel php artisan dusk:chrome-driver

Para rodar os testes, configure no .env (e aproveite coloque no .env.example):

APP_URL=http://cursolaravel
DUSK_DRIVER_URL='http://selenium:4444/wd/hub'
DUSK_START_MAXIMIZED=true
DUSK_HEADLESS_DISABLED=true

Criando uma classe do Dusk para inserirmos nosso teste:

docker exec -it cursolaravel php artisan dusk:make BuscaLivroTest

Criando um teste que verifica se na rota /livros existe a frase "Listagem de Livros":

$browser->visit('/livros')
    ->pause(2000)
    ->typeSlowly('search', 'primo', 300)
    ->pause(2000)
    ->press('Pesquisar')
    ->pause(2000)
    ->assertSee('O Primo Basílio');

Rodar o teste:

docker exec -it cursolaravel php artisan dusk tests/Browser/BuscaLivroTest.php

Exercício - Importação de Livros

1 - Criar um comando para importar os livros do arquivo csv livros no model Livro. Importante:

2 - Criar teste Dusk para buscar a string "processo" e deverá ter um assert para ver Franz Kafka e um assert not para José de Alencar;

3 - Criar estatísticas básicas sobre os dados importados

Exemplo de saída (com dados fictícios):

ano quantidade
1998 4
2000 7

4 - No método stats apresente uma segunda tabela com a quantidade de livros por autor.

Na próxima reunião, cada membro do grupo (estagiários e funcionários) deve apresentar na TV rapidamente e solução do exercício.


Dia 2 - CRUD

CRUD é um acrônimo para as quatro operações básicas utilizadas na manipulação de dados em sistemas web: Create (Criar), Read (Ler), Update (Atualizar) e Delete (Excluir). Essas operações interagem com bancos de dados, permitindo, por exemplo, que usuários possam cadastrar novas informações, visualizar registros existentes, modificar dados já salvos e remover registros.

Create

São geralmente necessárias duas rotas para salvar um registro em uma operação CRUD porque o processo é dividido em duas etapas: exibir o formulário e processar os dados enviados. A rota GET serve para exibir o formulário de criação e a rota POST serve para processar os dados enviados pelo formulário no controller:

Route::get('/livros/create', [LivroController::class,'create']);
Route::post('/livros', [LivroController::class,'store']);

Para mostrar o formulário html usamos o método create :

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

Formulário html resources/views/livros/create.blade.php com o seguinte conteúdo:

<form method="POST" action="/livros">
    @csrf
    Título: <input type="text" name="titulo">
    Autor: <input type="text" name="autor">
    Ano: <input type="text" name="ano">
    <button type="submit">Enviar</button>
</form>

Por fim o método store, que salva no banco de dados o cadastro do livro:

public function store(Request $request){
    $livro = new Livro;
    $livro->titulo = $request->titulo;
    $livro->autor = $request->autor;
    $livro->ano = $request->ano;
    $livro->save();
    return redirect('/livros');
}

Read

Já implementamos uma forma de acessar os livros em forma de listagem com o método index, podemos implementar outra forma de acesso individual para cada livro. Rota para acesso ao registro de um livro específico:

Route::get('/livros/{livro}', [LivroController::class,'show']);

Respectivo controller:

public function show(Livro $livro){
    return view('livros.show',[
        'livro' => $livro
    ]);
}

Criamos um blade para a rota show resources/views/livros/show.blade.php com o seguinte conteúdo:

Título: {{ $livro->titulo }} <br>
Autor: <i>{{ $livro->autor }}</i> <br>
Ano de publicação: {{ $livro->ano }} <br>
<a href="/livros">Voltar</a>

No index.blade.php podemos criar um link para o show de cada livro:

<a href="/livros/{{ $livro->id}}">{{ $livro->titulo }}</a>

Update

Novamente precisamos de duas rotas para atualizar um registro, uma para exibir o formulário e outra para processar os dados enviados.

Route::get('/livros/{livro}/edit', [LivroController::class,'edit']);
Route::patch('/livros/{livro}', [LivroController::class,'update']);

Implementação no controller:

public function edit(Livro $livro){
    return view('livros.edit',[
        'livro' => $livro
    ]);
}

public function update(Request $request, Livro $livro){
    $livro->titulo = $request->titulo;
    $livro->autor = $request->autor;
    $livro->ano = $request->ano;
    $livro->save();
    return redirect("/livros/{$livro->id}");
}

Criando o blade para edição:

touch resources/views/livros/edit.blade.php

Html para edição no edit.blade.php:

<form method="POST" action="/livros/{{ $livro->id }}">
    @csrf
    @method('PATCH')
    Título: <input type="text" name="titulo" value="{{ $livro->titulo }}">
    Autor: <input type="text" name="autor" value="{{ $livro->autor }}">
    Ano: <input type="text" name="ano" value="{{ $livro->ano }}">
    <button type="submit">Enviar</button>
</form>

Vamos colocar o botão para edição no blade show.blade.php:

<a href="/livros/{{ $livro->id }}/edit">Editar</a> <br>

Delete

Rota para delete:

Route::delete('/livros/{livro}', [LivroController::class,'destroy']);

Controller para delete:

public function destroy(Livro $livro)
{
    $livro->delete();
    return redirect('/livros');
}

Botão html para delete que podemos colocar no blade do show.blade.php:

<form action="/livros/{{ $livro->id }} " method="post">
    @csrf
    @method('delete')
    <button type="submit" onclick="return confirm('Tem certeza?');">Apagar</button> 
</form>

Implementação do dusk para testar as operações de CRUD:

php artisan dusk:make LivroCrudTest

Vamos testar sequencialmente as operações:

use App\Models\Livro;
public function test_curso(): void
{
    $this->browse(function (Browser $browser) {
        // Create
        $browser->visit('/livros/create')
            ->typeSlowly('titulo', '2001: Uma odisséia no espaço')
            ->typeSlowly('autor', 'Arthur C. Clarke')
            ->typeSlowly('ano', '1968')
            ->press('Enviar')
            ->assertPathIs('/livros')
            ->assertSee('2001: Uma odisséia no espaço');

        // Read
        $browser->clickLink('2001: Uma odisséia no espaço')
            ->assertSee('Arthur C. Clarke')
            ->assertSee('1968');

        // Update
        $browser->clickLink('Editar')
            ->typeSlowly('titulo', '2001: Uma odisséia no espaço - Edição Revisada')
            ->press('Enviar')
            ->assertSee('2001: Uma odisséia no espaço - Edição Revisada');

        // Delete
        $browser->press('Apagar')
            ->acceptDialog()
            ->assertPathIs('/livros')
            ->assertDontSee('2001: Uma odisséia no espaço - Edição Revisada');
    });
}

Rodando o teste:

docker exec -it cursolaravel php artisan dusk tests/Browser/LivroCrudTest.php

Exercício 2

  1. Criar um CRUD completo para o model frases.
  2. Criar uma classe dusk que testa todas funcionalidades do CRUD frases
  3. Criar um comando, importarfrases, que importa o arquivo csv: frases
  4. Criar uma rota /frasedodia e o método correspondente que ao ser acessada mostra uma frase aleatória, porém correspondente ao dia da semana.

Na próxima reunião, cada membro do grupo (estagiários e funcionários) deve apresentar a implementação na TV.


Dia 3 - Migrations de alteração, Validações, Mutators,

Instalação do template USP conforme: https://github.com/uspdev/laravel-usp-theme/

Instalação do senhaunica-socialite conforme: https://github.com/uspdev/senhaunica-socialite

Configurações para usar o faker:

APP_URL=http://localhost:8000
SENHAUNICA_KEY=faker
SENHAUNICA_SECRET=faker
SENHAUNICA_CALLBACK_ID=1
SENHAUNICA_ADMINS=111111
SENHAUNICA_DEV="http://auth.local:3141/wsusuario/oauth"

Migration de Alteração

Quando o sistema está produção, você nunca deve alterar uma migration que já foi para o ar, mas sim criar uma migration que altera uma anterior. Por exemplo, se quisermos que adicionar o campo user_id na tabela livros:

php artisan make:migration add_user_id_to_livros_table --table=livros

Nova coluna user_id:

$table->unsignedBigInteger('user_id')->nullable();

$table->foreign('user_id')->references('id')->on('users')->nullOnDelete();

Aplique a mudança no banco de dados:

php artisan migrate

No controller, é possível capturar o usuário logado assim: auth()->user()->id.

Validação

Validação no Controller

Quando estamos dentro de um método do controller, a forma mais rápida de validação é usando $request->validate, que validará os campos com as condições que passarmos e caso falhe a validação, automaticamente o usuário é retornado para página de origem com todos inputs que foram enviados na requisição, além da mensagens de erro:

$request->validate([
  'titulo' => 'required',
  'autor' => 'required',
  'ano' => 'required|integer',
]);

A função old('titulo') verifica se há input na sessão para o campo titulo, para o create.blade.php:

Título: <input type="text" name="titulo" value="{{old('titulo')}}">
Autor: <input type="text" name="autor" value="{{old('autor')}}">
Ano: <input type="text" name="ano" value="{{old('ano')}}">

E no para o edir.blade.php:

Título: <input type="text" name="titulo" value="{{ old('titulo', $livro->titulo) }}">
Autor: <input type="text" name="autor" value="{{ old('autor', $livro->autor) }}">
Ano: <input type="text" name="ano" value="{{ old('ano', $livro->ano) }}">

FormRequest

A validação, que muitas vezes será idêntica no store e no update, pode ser delegada para um FormRequest. Crie um FormRequest com o artisan:

docker exec -it cursolaravel php artisan make:request LivroRequest

Esse comando gerou o arquivo app/Http/Requests/LivroRequest.php. Como ainda não falamos de permissões, retorne true no método
authorize(). As validações podem ser implementada em rules().

public function rules(){
    $rules = [
        'titulo' => 'required',
        'autor'  => 'required',
        'ano' => 'required|integer',
    ];
    return $rules;
}

No controler, trocamos as chamadas de Request para LivroRequest:

use App\Http\Requests\LivroRequest;
public function update(LivroRequest $request, Livro $livro){
public function store(LivroRequest $request)

Mutators

Há situações em que queremos fazer um leve processamento antes de salvar um valor no banco de dados e logo após recuperarmos um valor. Vamos adicionar um campo para preço. Já sabemos como criar uma migration de alteração para alterar a tabela livros:

php artisan make:migration add_preco_column_in_livros --table=livros

E adicionamos na nova coluna:

$table->float('preco')->nullable();

No LivroRequest também deixaremos esse campo como opcional: 'preco' => 'nullable'.

Queremos que o usuário digite, por exemplo, 12,50, mas guardaremos 12.5. Quando quisermos mostrar o valor, vamos fazer a operação inversa. Poderíamos fazer esse tratamento diretamente no controller, mas também podemos usar mutators através no model do livro:

use Illuminate\Database\Eloquent\Casts\Attribute;

protected function preco(): Attribute
{
    return Attribute::make(
        get: fn($value) => number_format($value, 2, ',', ''),
        set: fn($value) => str_replace(',','.',$value)
    );
}

Exercício 3

  1. No exercício anterior, inserir o usuário no model de frases como nullable, e restringir o cadastro somente para usuários cadastrados, guardando o id do respectivo usuário que está realizando o cadastro;
  2. Alterar o Dusk do exercício anterior (frases) para realizar todos os testes com um usuário logado, para isso será necessário criar o usuário durante o teste;
  3. Faça uma migration de alteração para adicionar o campo pontuação para a frase (entre 0 e 10 - validação com FormRequest);
  4. Faça um mutator converter a virgula, quando existir, para ponto.
  5. Corrija seus formulários para sempre conterem a função old()

Dia 4 - Além do CRUD

Revisão do ambiente com o conteúdo visto até então:

docker run --rm -it \
  -v $(pwd):/app \
  -u $(id -u):$(id -g) \
  composer:latest \
  composer create-project uspdev/starter-ng cursolaravel

cd cursolaravel
docker compose up --build

Rodando as migrations:

docker exec -it cursolaravel composer dump-autoload -o
docker exec -it cursolaravel php artisan migrate:fresh

Acessar o laravel criado: http://127.0.0.1:8000/

Gerando o CRUD para livros:

docker exec -it cursolaravel php artisan scg
docker exec -it cursolaravel php artisan migrate

Mude o .env e rode o testes com o dusk:

docker exec -it cursolaravel php artisan dusk:chrome-driver
docker exec -it cursolaravel php artisan dusk tests/Browser/LivroCrudTest.php

Acessar http://localhost:7900/ com senha secret e assistir o teste rodando.

Relacionamentos

Na tabela livros já inserimos o campo user_id apontando para tabela users. Vamos agora criar as relações (one-to-many) no laravel:

Model Livro:

public function user()
{
    return $this->belongsTo(User::class);
}

Nos fornece o poder de acessar o objeto usuário a partir do livro, exemplo no show.blade.php

<p>
    Livro cadastrado em {{ $livro->created_at->format('d/m/Y H:i') }} <br>
    Última atualização em {{ $livro->updated_at->format('d/m/Y H:i') }} por <b>{{ $livro->user?->name }}</b> 
</p>

Model User:

public function livros()
{
    return $this->hasMany(Livro::class);
}

Nos fornece o poder de acessar todos os objetos de livros a partir de um usuário, exemplo no show.blade.php:

<div>
    @if($livro->user)
        Outros livros cadastrados ou editados por {{ $livro->user?->name }}:
        <ul>
        @foreach($livro->user->livros as $outro_livro)
            <li>{{ $outro_livro->titulo }}</li>
        @endforeach
        </ul>
    @endif
</div>

Observer e Emails

Um Observer é uma classe que escuta e reage a eventos do ciclo de vida de uma Model, como created, updated ou deleted:

docker exec -it cursolaravel php artisan make:observer LivroObserver --model=Livro

Criando um template de email na pasta resources/views/emails/livros com o nome create.blade.php:

mkdir -p resources/views/emails/livros
touch resources/views/emails/livros/create.blade.php

Template:

Novo livro criado: {{ $livro->titulo }}

Criando a rotina de envio de email:

docker exec -it cursolaravel php artisan make:mail LivroCreatedMail

Configurando o email com ShouldQueue para que o Laravel envie o email em segundo plano:

use App\Models\Livro;
class LivroCreatedMail extends Mailable implements ShouldQueue 
{
    private Livro $livro;
    public function __construct(Livro $livro)
    {
        $this->livro = $livro;
    }

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Novo Livro Cadastrado: ' . $this->livro->titulo,
        );
    }

    public function content(): Content
    {
        return new Content(
            view: 'emails.livros.create',
            with: [
                'livro' => $this->livro,
            ],
        );
    }

Configurando o observer para disparar o email na ação de livro criado:

namespace App\Observers;

use App\Models\Livro;
use App\Mail\LivroCreatedMail;
use Illuminate\Support\Facades\Mail;

class LivroObserver
{
    public function created(Livro $livro): void
    {
        Mail::to('destinatario@email.com')->queue(new LivroCreatedMail($livro));
    }
}

Registrando o observer no AppServiceProvider.php:

use App\Models\Livro;
use App\Observers\LivroObserver;

    public function boot(): void
    {
        Livro::observe(LivroObserver::class);
    }
}

Agrupe os testes de e-mail do mesmo Model em uma única classe, utilizando o prefixo Livro para identificar os testes da Model Livro, neste caso:

docker exec -it cursolaravel php artisan dusk:make LivroEmailsTest

Implemente o teste Dusk para confirmar se o email está sendo disparado:

<?php

namespace Tests\Browser;

use Illuminate\Foundation\Testing\DatabaseMigrations;
use Laravel\Dusk\Browser;
use Tests\DuskTestCase;
use Illuminate\Support\Facades\Http;

use App\Mail\LivroCriadoMail;
use Illuminate\Support\Facades\Mail;

class LivroEmailsTest extends DuskTestCase
{
    protected function setUp(): void
    {
        // Limpa as mensagens do Mailpit antes de cada teste
        parent::setUp();
        Http::delete('http://mailpit:8025/api/v1/messages');
    }

    public function test_create_livro(): void
    {
        Mail::fake();
        $this->browse(function (Browser $browser) {
            // Login
            $browser->visit('/')
                ->clickLink('Entrar')
                ->waitFor('#loginUsuario')
                ->typeSlowly('#loginUsuario', '111111')
                ->press('Login');

            // Create
            $browser->visit('/livros/create')
                ->typeSlowly('titulo', '2001: Uma odisséia no espaço')
                ->typeSlowly('autor', 'Arthur C. Clarke')
                ->typeSlowly('ano', '1968')
                ->press('Enviar')
                ->assertPathIs('/livros')
                ->assertSee('2001: Uma odisséia no espaço');
        });

        // Consulta a API do Mailpit para verificar se o e-mail foi entregue
        $response = Http::get('http://mailpit:8025/api/v1/messages');
        $messages = $response->json('messages');
        $latestMail = $messages[0];

        // Valida o assunto do e-mail enviado pela Mailable
        $this->assertStringContainsString('Novo Livro Cadastrado: ' . '2001: Uma odisséia no espaço', $latestMail['Subject']);

        // Valida se o destinatário é o correto 
        $this->assertEquals('destinatario@email.com', $latestMail['To'][0]['Address']);
    }
}

Replicado USP

Na USP, o banco de dados corporativo central (Sybase/SQL Server) é replicado para bases de dados locais nas unidades, esse banco local é chamado de Replicado.

docker exec -it cursolaravel composer require uspdev/replicado

Como funciona?

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Uspdev\Replicado\Pessoa;

class IndexController extends Controller
{
    public function index(){
        if (auth()->check()) {
            $curso = Pessoa::retornarCursoPorCodpes(auth()->user()->codpes)['nomcur'];
        } else {
            $curso = 'usuário não logado';
        }
        return view('index', ['curso' => $curso]);
    }
}

No blade:

Seu curso é: {{ $curso }}

Upload

Exemplo de upload para uma imagem por livro. Usaremos uma abordagem mais segura porque impede o acesso público direto via URL. Quando os arquivos ficam na pasta pública, qualquer pessoa que descubra o link pode visualizá-los, ignorando a autenticação do sistema. No diretório privado, a entrega do arquivo obrigatoriamente passa por uma rota e um método no Controller. Isso permite validar se o usuário está logado e aplicar políticas de permissão, garantindo total controle sobre quem pode visualizar ou baixar o conteúdo.

No arquivo App/Http/Requests/LivroRequest.php:

'imagem'   => 'nullable|image|mimes:jpeg,jpg|max:2048',

Formulário de criação:

<form method="POST" action="/livros" enctype="multipart/form-data">
    ...
    Capa (JPEG): <input type="file" name="imagem" accept="image/jpeg">
    ...
</form>

Formulário de edição:

<form method="POST" action="/livros/{{ $livro->id }}" enctype="multipart/form-data">
    ...
    @if($livro->imagem_path)
        <img src="/livros/imagem/{{ $livro->id }}" width="200px"> <br>
    @endif
    Imagem: <input type="file" name="imagem" accept="image/jpeg">
    ...
</form>

Migration de alteração:

docker exec -it cursolaravel php artisan make:migration add_imagem_column_in_livros --table=livros

Novos campos:

$table->string('imagem_original_name')->nullable();
$table->string('imagem_path')->nullable();

Aplicando

docker exec -it cursolaravel php artisan migrate

No controller, store e update:

if ($request->hasFile('imagem')) {
    $livro->imagem_original_name = $request->file('imagem')->getClientOriginalName();
    $livro->imagem_path = $request->file('imagem')->store('livros');
}

Rota e método a para ver a imagem, pois por padrão estamos colocando-as numa pasta privada e método para remoção do arquivo:

use Illuminate\Support\Facades\Storage;

# rota: Route::get('/livros/imagem/{livro}', [LivroController::class,'imagem']);
public function imagem(Livro $livro)
{
    return Storage::download($livro->imagem_path, $livro->imagem_original_name);
}

# rota: Route::delete('/livros/imagem/{livro}', [LivroController::class,'destroy_imagem']);
public function destroy_imagem(Livro $livro)
{
    if ($livro->imagem_path && Storage::exists($livro->imagem_path)) {
        Storage::delete($livro->imagem_path);
        $livro->imagem_path = null;
        $livro->imagem_original_name = null;
        $livro->save();
    }
    return back();
}

# atualizar
public function destroy(Livro $livro)
{
    if ($livro->imagem_path && Storage::exists($livro->imagem_path)) {
        Storage::delete($livro->imagem_path);
    }
    $livro->delete();
    return redirect('/livros');
}

No show.blade.php:

@if($livro->imagem_path)
    <img src="/livros/imagem/{{ $livro->id }}" width="200px"> <br>

    <form action="/livros/imagem/{{ $livro->id }} " method="post">
    @csrf
    @method('delete')
    <button type="submit" onclick="return confirm('Tem certeza?');">Deletar Imagem</button> 
</form>
@endif

Teste de upload com o dusk:

use Illuminate\Http\UploadedFile;

$this->browse(function (Browser $browser) {
    $image1 = UploadedFile::fake()->image('imagem1.jpg', 640, 480);
    $image2 = UploadedFile::fake()->image('imagem2.jpg', 640, 480);

    # create
    $browser->visit('/livros/create')
        ->attach('imagem', $image1->getPathname())

    # update 
    $browser->clickLink('Editar')
        ->attach('imagem', $image2->getPathname())

    # delete imagem
    $browser->press('Deletar Imagem')
        ->acceptDialog();

Atenção
Quando um livro pode ter múltiplos arquivos associados (como fotos de capa, sumário, anexos ou capítulos em PDF), a melhor prática é criar um Model entidade, como por exemplo, LivroArquivo via relacionamento Um para Muitos (hasMany) ao invés de fazer no model do livro como feito aqui, entretanto, a parte de manipulação do arquivo, continua exatamente a mesma.

Dia 5 - Além do CRUD - continuação

Autorização

Um Gate no Laravel é uma função usada para verificar se um usuário autenticado tem permissão para realizar uma ação específica na aplicação. O senha única socialite fornece acesso para um Gate chamado de admin para aqueles usuários com número USP no .env: SENHAUNICA_ADMINS=111111,782783, no exemplo, somente as pessoas com número USP 111111 e 782783 terão acesso de admim. Para restringir o acesso a controllers somente para admin:

use Illuminate\Support\Facades\Gate;

public function index(){
    Gate::authorize('admin');
    ...
}

Ou no blade:

@can('admin')
  {{-- Verificar permission customizada --}}
@endcan

criando algumas permissões a serem utilizadas pela aplicação:

docker exec -it cursolaravel php artisan make:migration seed_permission_table

Arquivo com os Gates:

use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
...

public function up()
{
    Permission::firstOrCreate(['name' => 'vigilantes']);
    Permission::firstOrCreate(['name' => 'limpeza']);
}
docker exec -it cursolaravel php artisan migrate

excel e pdf - além do CRUD, relatórios

Importando csv com os livros:

mkdir -p app/Console/Commands
curl -L https://fflch.github.io/assets/laravel/ImportaLivros.php -o app/Console/Commands/ImportaLivros.php

docker exec -it cursolaravel composer require league/csv
docker exec -it cursolaravel php artisan app:importa-livros
docker exec -it cursolaravel composer require phpoffice/phpspreadsheet

Gerando excel

use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;

# rota: Route::get('/livros/excel', [LivroController::class,'excel']);
public function excel(Request $request)
{
    if ($request->has('search')) {
        $livros = Livro::where('titulo', 'like', '%' . $request->search . '%')->get();
    } else {
        $livros = Livro::all();
    }

    $spreadsheet = new Spreadsheet();
    $sheet = $spreadsheet->getActiveSheet();

    // Cabeçalho
    $sheet->fromArray([
        ['Título', 'Autor', 'Ano']
    ]);

    // Dados
    $linha = 2;
    foreach ($livros as $livro) {
        $sheet->fromArray([
            $livro->titulo,
            $livro->autor,
            $livro->ano,
        ], null, "A{$linha}");

        $linha++;
    }

    $writer = new Xlsx($spreadsheet);

    return response()->streamDownload(
        fn() => $writer->save('php://output'),
        'livros.xlsx'
    );
}

No blade:

<a href="/livros/excel/?search={{ request('search') }}" class="btn btn-success">
    Exportar Excel
</a>

Teste no Dusk simples, somente verificando se a rota entrega um arquivo excel:

$browser->visit('/livros/excel?search=Editado');
$response = $this->get('/livros/excel?search=Editado');
$response->assertStatus(200);
$response->assertHeader('content-disposition', 'attachment; filename=livros.xlsx');

Gerando Pdf no padrão da FFLCH

Instalação

docker exec -it cursolaravel composer require fflch/laravel-fflch-pdf
docker exec -it cursolaravel php artisan vendor:publish --provider="Barryvdh\DomPDF\ServiceProvider"
docker exec -it cursolaravel php artisan vendor:publish --provider="Fflch\LaravelFflchPdf\LaravelFflchPdfServiceProvider"

No seu .env, configure o cabeçalho do pdf: FFLCHPDF_SETOR='Setor de Graduação'.

use PDF;

# rota: Route::get('/livros/pdf', [LivroController::class,'pdf']);
public function pdf(Request $request)
{
    if ($request->filled('search')) {
        $livros = Livro::where('titulo', 'like', '%' . $request->search . '%')->get();
    } else {
        $livros = Livro::all();
    }

    $pdf = PDF::loadView('livros.pdf', [
        'livros' => $livros,
    ]);

    return $pdf->download('livros.pdf');
}

Formatação do pdf em resources/views/livros/pdf.blade.php:

@extends('laravel-fflch-pdf::main')

@section('content')

<h1>Relação de Livros</h1>

<table width="100%" border="1" cellspacing="0" cellpadding="5">
    <thead>
        <tr>
            <th>Título</th>
            <th>Autor</th>
            <th>Ano</th>
        </tr>
    </thead>
    <tbody>
        @foreach($livros as $livro)
        <tr>
            <td>{{ $livro->titulo }}</td>
            <td>{{ $livro->autor }}</td>
            <td>{{ $livro->ano }}</td>
        </tr>
        @endforeach
    </tbody>
</table>
@endsection

No blade:

<a href="/livros/pdf/?search={{ request('search') }}" class="btn btn-success">
    Exportar Pdf
</a>

Por fim, um teste no dusk simples verificando se o pdf foi gerado:

$browser->visit('/livros/pdf?search=Editado');
$response = $this->get('/livros/pdf?search=Editado');
$response->assertStatus(200);
$response->assertHeader('content-disposition', 'attachment; filename=livros.pdf');

Workflow

Implementando a lógica no model quando o sistema existe etapas, exemplo: pedido, análise e aprovado.

Biblioteca para registro de status:

docker exec -it cursolaravel composer require spatie/laravel-model-status
docker exec -it cursolaravel php artisan vendor:publish --provider="Spatie\ModelStatus\ModelStatusServiceProvider" --tag="migrations"
docker exec -it cursolaravel php artisan migrate

Infomando o Model que ele tem status:

use Spatie\ModelStatus\HasStatuses;

class Livro extends Model
{
    use HasStatuses;
}

No controller do Livro colocar status no create e no update respectivamente:

$livro->setStatus('pedido');
$livro->setStatus('aprovado');

Biblioteca da FFLCH para ver os status:

docker exec -it cursolaravel composer require fflch/laravel-fflch-stepper
docker exec -it cursolaravel php artisan vendor:publish --provider="Fflch\LaravelFflchStepper\LaravelFflchStepperServiceProvider" --tag="config"

Seguir a documentação da biblioteca em https://github.com/fflch/laravel-fflch-stepper

Conteúdos extra

Exercício

1 - Fazer auditoria do model Livro no qual o administrador poderá ver todos usuários que alteraram um determinado livro e quando. Usar owen-it/laravel-auditing. Caso real de uso: https://github.com/uspdev/copaco/blob/master/resources/views/partials/audit/index.blade.php

2 - Criar uma rotina de envio de email quando um livro for cadastrado. Ao invés deixar fixo o texto do email no sistema no arquivo blade, dar a opção do usuário do sistema alterar partes do texto do email, para isso criar uma área de configuração no sistema que poderá ser usada pelos usuários para alterar os emails. Usar spatie/laravel-settings. Caso real de uso: https://github.com/uspdev/estagios/blob/master/resources/views/emails/alteracao.blade.php

Instruções para produção

1 - Imagem do docker

Para construção da imagem baseada na tag (versão) criar o arquivo .github/workflows/docker.yml:
Arquivo modelo docker.yml

mkdir -p .github/workflows
curl -L https://fflch.github.io/assets/laravel/docker.yml -o .github/workflows/docker.yml

2 - testes no dusk

Para rodar os testes no dusk baseado na configuração do docker-compose.yml, criar o arquivo .github/workflows/dusk.yml:
Arquivo modelo dusk.yml

curl -L https://fflch.github.io/assets/laravel/dusk.yml -o .github/workflows/dusk.yml

É necessário alterar a variável SERVICE_NAME no dusk.yml colocando o nome do sistema, que no caso do curso é cursolaravel.

3 - .env.example

Além disso o .env.example do sistema deve estar preparado para rodar os testes no dusk com o ambiente criado pelo docker-compose.yml, ou seja, o .env.example deve conter minimamente:

Arquivo modelo .env.example

# Configurações Mínimas em dev

QUEUE_CONNECTION=sync

MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=
MAIL_PASSWORD=
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=test@example.com
MAIL_FROM_NAME="Laravel"

DB_CONNECTION=mariadb
DB_HOST=mariadb
DB_PORT=3306
DB_DATABASE=cursolaravel # TROCAR PARA NOME DO SISTEMA
DB_USERNAME=cursolaravel # TROCAR PARA NOME DO SISTEMA
DB_PASSWORD=cursolaravel # TROCAR PARA NOME DO SISTEMA

APP_URL=http://cursolaravel # TROCAR PARA NOME DO SISTEMA
DUSK_DRIVER_URL='http://selenium:4444/wd/hub'
DUSK_START_MAXIMIZED=true
DUSK_HEADLESS_DISABLED=true

SENHAUNICA_KEY=faker
SENHAUNICA_SECRET=faker
SENHAUNICA_CALLBACK_ID=1
SENHAUNICA_ADMINS=111111
SENHAUNICA_DEV="http://auth.local:3141/wsusuario/oauth"

USP_THEME_SKIN=fflch # ou uspdev
curl -L https://fflch.github.io/assets/laravel/env.example >> .env.example