Chegamos ao projeto final. Quarenta e nove artigos de fundamentos, orientação a objetos, bancos de dados, web, segurança, frameworks, testes e deploy. Agora é hora de integrar tudo isso em um sistema real, coeso e profissional.
O projeto é uma plataforma de e-commerce chamada Mercatto — simples o suficiente para ser implementado em três semanas, complexo o suficiente para exercitar todos os conceitos aprendidos. Não vamos construir um sistema com todas as funcionalidades imagináveis. Vamos construir um sistema bem feito, com as funcionalidades certas, do jeito certo.
Escopo do sistema
O que o Mercatto faz:
- Catálogo de produtos com categorias, busca e filtros
- Carrinho de compras persistido em sessão
- Checkout com endereço de entrega
- Processamento de pedidos com status
- Painel do cliente: pedidos, histórico, perfil
- Painel administrativo: produtos, pedidos, relatórios
- API REST para integração com apps mobile
- Notificações por email em eventos importantes
O que deliberadamente não está no escopo:
- Integração com gateway de pagamento real (simulamos)
- Sistema de avaliações de produtos
- Multi-tenant / múltiplos vendedores
- Internacionalização
Escopo claro é tão importante quanto o código. Um projeto sem fronteiras definidas nunca termina.
Arquitetura — decisões e justificativas
Antes de escrever uma linha de código, tomamos decisões arquiteturais. Cada decisão tem uma justificativa — não escolhemos tecnologias por modismo, mas por adequação ao problema.
Arquitetura em camadas:
┌─────────────────────────────────────────────┐
│ HTTP Layer │
│ Routes → Middleware → Controllers │
├─────────────────────────────────────────────┤
│ Application Layer │
│ Form Requests → Services → Jobs │
├─────────────────────────────────────────────┤
│ Domain Layer │
│ Models → Value Objects → Domain Events │
├─────────────────────────────────────────────┤
│ Infrastructure Layer │
│ Eloquent Repositories → Cache → Queue │
└─────────────────────────────────────────────┘
Essa separação garante que a lógica de negócio (Domain) não depende de como os dados são persistidos (Infrastructure) nem de como chegam ao sistema (HTTP). Você troca o banco de dados sem tocar nas regras de negócio. Testa as regras sem subir o servidor HTTP.
Stack tecnológico:
| Camada | Tecnologia | Justificativa |
|---|---|---|
| Framework | Laravel 11 | Produtividade, ecossistema, convenções sólidas |
| Banco de dados | PostgreSQL 16 | JSONB para atributos de produto, confiabilidade |
| Cache | Redis 7 | Cache de queries, sessões, filas |
| Filas | Laravel Queue + Redis | Emails e processamento assíncrono |
| Testes | Pest + PostgreSQL 16 em container | Mesmo banco da produção — ver a nota abaixo |
| Deploy | Docker + GitHub Actions | Reprodutibilidade e automação |
Por que os testes não rodam em SQLite aqui. A troca é tentadora — SQLite
em memória é ordens de grandeza mais rápido — e não sobrevive a este schema. Metade das
tabelas abaixo nem chega a ser criada: o DEFAULT NOW() é erro de sintaxe no SQLite
(near "("), e o índice USING gin também. Pior do que o erro é o que
passa: como o SQLite é de tipagem dinâmica, ele aceita
SERIAL, TIMESTAMPTZ e JSONB como nomes de tipo quaisquer —
e aí SERIAL PRIMARY KEY deixa de ser chave autoincrementada. Medido: duas inserções
numa tabela assim gravam id = NULL nas duas linhas, sem erro nenhum, enquanto a mesma
DDL no PostgreSQL devolve 1 e 2. Um teste que roda contra esse banco não está testando o
sistema; está testando outro sistema, parecido. Onde o schema usa recurso específico do
PostgreSQL — e este usa —, o teste de integração sobe um PostgreSQL de verdade, em
container. Os testes de unidade, esses sim, continuam sem banco algum.
Modelagem do banco de dados
-- Schema completo do Mercatto
CREATE TABLE categorias (
id SERIAL PRIMARY KEY,
nome VARCHAR(100) NOT NULL,
slug VARCHAR(120) NOT NULL UNIQUE,
descricao TEXT,
ativa BOOLEAN NOT NULL DEFAULT TRUE,
criada_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizada_em TIMESTAMPTZ
);
CREATE TABLE produtos (
id SERIAL PRIMARY KEY,
categoria_id INTEGER NOT NULL REFERENCES categorias(id),
nome VARCHAR(200) NOT NULL,
slug VARCHAR(220) NOT NULL UNIQUE,
descricao TEXT,
preco NUMERIC(10,2) NOT NULL CHECK (preco >= 0),
preco_promocional NUMERIC(10,2) CHECK (preco_promocional >= 0),
estoque INTEGER NOT NULL DEFAULT 0 CHECK (estoque >= 0),
ativo BOOLEAN NOT NULL DEFAULT TRUE,
atributos JSONB, -- cor, tamanho, material, etc.
imagens JSONB, -- array de paths
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ,
deleted_at TIMESTAMPTZ -- soft delete
);
CREATE TABLE usuarios (
id SERIAL PRIMARY KEY,
nome VARCHAR(150) NOT NULL,
email VARCHAR(200) NOT NULL UNIQUE,
senha_hash VARCHAR(255) NOT NULL,
cpf_cript TEXT, -- AES-256-GCM
telefone_cript TEXT,
role VARCHAR(20) NOT NULL DEFAULT 'cliente',
ativo BOOLEAN NOT NULL DEFAULT TRUE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE enderecos (
id SERIAL PRIMARY KEY,
usuario_id INTEGER NOT NULL REFERENCES usuarios(id),
rotulo VARCHAR(50) NOT NULL DEFAULT 'Casa',
cep VARCHAR(9) NOT NULL,
logradouro VARCHAR(200) NOT NULL,
numero VARCHAR(20) NOT NULL,
complemento VARCHAR(100),
bairro VARCHAR(100) NOT NULL,
cidade VARCHAR(100) NOT NULL,
estado CHAR(2) NOT NULL,
principal BOOLEAN NOT NULL DEFAULT FALSE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE pedidos (
id SERIAL PRIMARY KEY,
usuario_id INTEGER NOT NULL REFERENCES usuarios(id),
endereco_id INTEGER NOT NULL REFERENCES enderecos(id),
status VARCHAR(30) NOT NULL DEFAULT 'pendente',
subtotal NUMERIC(10,2) NOT NULL,
desconto NUMERIC(10,2) NOT NULL DEFAULT 0,
frete NUMERIC(10,2) NOT NULL DEFAULT 0,
total NUMERIC(10,2) NOT NULL,
observacoes TEXT,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ,
CONSTRAINT status_valido CHECK (
status IN ('pendente','confirmado','em_preparo','enviado','entregue','cancelado')
)
);
CREATE TABLE pedido_itens (
id SERIAL PRIMARY KEY,
pedido_id INTEGER NOT NULL REFERENCES pedidos(id) ON DELETE CASCADE,
produto_id INTEGER NOT NULL REFERENCES produtos(id),
nome_produto VARCHAR(200) NOT NULL, -- snapshot do nome no momento da compra
preco_unitario NUMERIC(10,2) NOT NULL, -- snapshot do preço
quantidade INTEGER NOT NULL CHECK (quantidade > 0),
subtotal NUMERIC(10,2) NOT NULL
);
CREATE TABLE personal_access_tokens (
id BIGSERIAL PRIMARY KEY,
tokenable_type VARCHAR(255) NOT NULL,
tokenable_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
abilities TEXT,
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ
);
-- Índices de performance
CREATE INDEX idx_produtos_categoria ON produtos(categoria_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_produtos_ativo ON produtos(ativo, estoque) WHERE deleted_at IS NULL;
CREATE INDEX idx_produtos_slug ON produtos(slug);
CREATE INDEX idx_pedidos_usuario ON pedidos(usuario_id);
CREATE INDEX idx_pedidos_status ON pedidos(status, criado_em DESC);
CREATE INDEX idx_produtos_atributos ON produtos USING gin(atributos);
Estrutura de diretórios do projeto
mercatto/
├── app/
│ ├── Domain/
│ │ ├── Carrinho/
│ │ │ ├── Carrinho.php
│ │ │ ├── ItemDoCarrinho.php
│ │ │ └── CarrinhoService.php
│ │ ├── Pedido/
│ │ │ ├── Pedido.php
│ │ │ ├── StatusDoPedido.php ← Enum
│ │ │ └── PedidoService.php
│ │ └── Produto/
│ │ ├── Produto.php
│ │ └── Dinheiro.php ← Value Object
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── Web/
│ │ │ │ ├── CatalogoController.php
│ │ │ │ ├── CarrinhoController.php
│ │ │ │ └── CheckoutController.php
│ │ │ ├── Admin/
│ │ │ │ ├── ProdutoController.php
│ │ │ │ └── PedidoController.php
│ │ │ └── Api/
│ │ │ ├── AuthController.php
│ │ │ └── ProdutoController.php
│ │ ├── Middleware/
│ │ └── Requests/
│ ├── Jobs/
│ │ ├── ProcessarPedido.php
│ │ └── EnviarEmailDePedido.php
│ ├── Models/
│ ├── Providers/
│ └── Services/
├── database/migrations/
├── resources/views/
│ ├── layouts/
│ ├── catalogo/
│ ├── carrinho/
│ ├── checkout/
│ ├── pedidos/
│ └── admin/
├── routes/
│ ├── web.php
│ ├── admin.php
│ └── api.php
└── tests/
├── Unit/
│ ├── Domain/
│ └── Services/
└── Feature/
├── Web/
└── Api/
Value Objects e Enums do domínio
<?php
declare(strict_types=1);
namespace App\Domain\Produto;
final readonly class Dinheiro
{
public function __construct(private int $centavos)
{
if ($centavos < 0) {
throw new \InvalidArgumentException('Valor monetário não pode ser negativo.');
}
}
public static function dereais(float $reais): self
{
return new self((int) round($reais * 100));
}
public function centavos(): int { return $this->centavos; }
public function reais(): float { return $this->centavos / 100; }
public function somar(self $outro): self
{
return new self($this->centavos + $outro->centavos);
}
public function subtrair(self $outro): self
{
if ($outro->centavos > $this->centavos) {
throw new \DomainException('Subtração resultaria em valor negativo.');
}
return new self($this->centavos - $outro->centavos);
}
public function multiplicar(int $fator): self
{
return new self($this->centavos * $fator);
}
public function aplicarDesconto(float $percentual): self
{
if ($percentual < 0 || $percentual > 100) {
throw new \InvalidArgumentException("Percentual inválido: {$percentual}");
}
$desconto = (int) round($this->centavos * $percentual / 100);
return new self($this->centavos - $desconto);
}
public function formatar(): string
{
return 'R$ ' . number_format($this->reais(), 2, ',', '.');
}
public function equals(self $outro): bool
{
return $this->centavos === $outro->centavos;
}
public function maiorQue(self $outro): bool
{
return $this->centavos > $outro->centavos;
}
}
<?php
declare(strict_types=1);
namespace App\Domain\Pedido;
enum StatusDoPedido: string
{
case Pendente = 'pendente';
case Confirmado = 'confirmado';
case EmPreparo = 'em_preparo';
case Enviado = 'enviado';
case Entregue = 'entregue';
case Cancelado = 'cancelado';
public function label(): string
{
return match($this) {
self::Pendente => 'Aguardando confirmação',
self::Confirmado => 'Confirmado',
self::EmPreparo => 'Em preparação',
self::Enviado => 'Enviado',
self::Entregue => 'Entregue',
self::Cancelado => 'Cancelado',
};
}
public function cor(): string
{
return match($this) {
self::Pendente => 'amarelo',
self::Confirmado => 'azul',
self::EmPreparo => 'laranja',
self::Enviado => 'roxo',
self::Entregue => 'verde',
self::Cancelado => 'vermelho',
};
}
public function podeTransicionarPara(self $proximo): bool
{
return match($this) {
self::Pendente => in_array($proximo, [self::Confirmado, self::Cancelado]),
self::Confirmado => in_array($proximo, [self::EmPreparo, self::Cancelado]),
self::EmPreparo => in_array($proximo, [self::Enviado, self::Cancelado]),
self::Enviado => $proximo === self::Entregue,
self::Entregue,
self::Cancelado => false, // estados finais
};
}
}
Carrinho de compras — lógica de domínio
<?php
declare(strict_types=1);
namespace App\Domain\Carrinho;
use App\Domain\Produto\Dinheiro;
final class ItemDoCarrinho
{
public function __construct(
public readonly int $produtoId,
public readonly string $nomeProduto,
public readonly Dinheiro $precoUnitario,
private int $quantidade,
) {
if ($quantidade <= 0) {
throw new \InvalidArgumentException('Quantidade deve ser maior que zero.');
}
}
public function quantidade(): int { return $this->quantidade; }
public function subtotal(): Dinheiro
{
return $this->precoUnitario->multiplicar($this->quantidade);
}
// Os dois métodos abaixo passam pelo CONSTRUTOR de propósito. Com
// `clone $this` seguido de atribuição direta, a validação de
// "quantidade maior que zero" fica valendo só para o primeiro item:
// `comQuantidade(0)` devolveria um item fantasma de subtotal R$ 0,00
// dentro do carrinho, e `comQuantidade(-5)` só estouraria mais tarde,
// lá dentro de `Dinheiro`, longe de quem causou o problema.
public function incrementar(int $qtd = 1): self
{
return $this->comQuantidade($this->quantidade + $qtd);
}
public function comQuantidade(int $quantidade): self
{
return new self($this->produtoId, $this->nomeProduto, $this->precoUnitario, $quantidade);
}
}
final class Carrinho
{
/** @var array<int, ItemDoCarrinho> */
private array $itens = [];
public function adicionar(ItemDoCarrinho $novoItem): void
{
if (isset($this->itens[$novoItem->produtoId])) {
$this->itens[$novoItem->produtoId] = $this->itens[$novoItem->produtoId]
->incrementar($novoItem->quantidade());
} else {
$this->itens[$novoItem->produtoId] = $novoItem;
}
}
public function atualizar(int $produtoId, int $quantidade): void
{
if (!isset($this->itens[$produtoId])) {
throw new \DomainException("Produto {$produtoId} não está no carrinho.");
}
if ($quantidade <= 0) {
$this->remover($produtoId);
return;
}
$this->itens[$produtoId] = $this->itens[$produtoId]->comQuantidade($quantidade);
}
public function remover(int $produtoId): void
{
unset($this->itens[$produtoId]);
}
public function limpar(): void
{
$this->itens = [];
}
public function total(): Dinheiro
{
return array_reduce(
$this->itens,
fn(Dinheiro $acc, ItemDoCarrinho $item) => $acc->somar($item->subtotal()),
new Dinheiro(0),
);
}
public function quantidadeDeItens(): int
{
return array_sum(array_map(fn($i) => $i->quantidade(), $this->itens));
}
public function estaVazio(): bool { return empty($this->itens); }
/** @return array<int, ItemDoCarrinho> */
public function itens(): array { return $this->itens; }
}
Nada do que foi escrito até aqui roda. Um schema, um diagrama de camadas, uma tabela de escolhas com justificativa, duas classes de domínio e um enum — e ainda assim esta é a parte do projeto em que as decisões custam mais caro, porque são as únicas que ficam difíceis de desfazer depois. Trocar um controller é uma tarde; trocar a decisão de guardar dinheiro em centavos, depois de trinta arquivos assumirem float, é uma semana e um bug de arredondamento que aparece na contabilidade. O escopo negativo — a lista do que o Mercatto deliberadamente não faz — vale pelo mesmo motivo: é ele que permite dizer não daqui a duas semanas sem reabrir a discussão inteira.
Repare no que o Carrinho desta parte não tem: nenhuma menção a sessão, a banco, a requisição HTTP. Ele soma itens e devolve totais, e é testável num arquivo solto, sem subir nada. Essa fronteira é a razão de existir da camada de domínio, e ela se defende sozinha na próxima parte, quando o Eloquent, os controllers e a fila entrarem em cena — cada um deles precisará se adaptar ao domínio, e não o contrário. Se em algum momento o Carrinho precisar de um use Illuminate\... no topo, é sinal de que a fronteira vazou.
Fontes e leituras recomendadas
- Laravel Application Structure: https://laravel.com/docs/structure
- Domain-Driven Design (livro) — Eric Evans: https://www.amazon.com.br/Domain-Driven-Design-Eric-Evans/dp/0321125215
- PostgreSQL Schema Design: https://www.postgresql.org/docs/current/ddl.html
- C4 Model — quatro niveis de diagrama de arquitetura, de contexto a codigo: https://c4model.com/
- Architecture Decision Records — registrar por que a decisao foi tomada, nao so qual foi: https://adr.github.io/
- Laravel — container de servicos, onde a inversao de dependencia acontece no framework: https://laravel.com/docs/container
Exercícios
Exercício 1
Configure o projeto Laravel com PostgreSQL, Redis e as migrations do schema acima. Execute php artisan migrate com sucesso.
Ver resposta
✓ Resposta: Duas decisões de schema que se pagam ao longo do projeto inteiro. Preço em centavos inteiros: casa direto com a classe Dinheiro e elimina qualquer conversão float entre banco e PHP. E o nome e o preço copiados no item do pedido — sem isso, renomear um produto no catálogo reescreve o histórico de todas as compras antigas. A coluna tsvector gerada é o bônus do PostgreSQL: o banco a mantém sincronizada sozinho, sem trigger e sem alguém esquecer de atualizá-la.
<?php
// ============ no terminal ============
// composer create-project laravel/laravel mercatto && cd mercatto
// composer require predis/predis
//
// docker compose up -d (postgres + redis; ver o #205)
// ============ .env ============
// APP_NAME=Mercatto
// APP_URL=http://localhost:8000
//
// DB_CONNECTION=pgsql
// DB_HOST=127.0.0.1
// DB_PORT=5432
// DB_DATABASE=mercatto
// DB_USERNAME=mercatto
// DB_PASSWORD=troque-aqui
//
// REDIS_CLIENT=predis
// REDIS_HOST=127.0.0.1
// REDIS_PORT=6379
//
// CACHE_STORE=redis
// SESSION_DRIVER=redis
// QUEUE_CONNECTION=redis
// ------------------- database/migrations/..._create_produtos_table.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('produtos', function (Blueprint $tabela): void {
$tabela->id();
$tabela->foreignId('categoria_id')
->constrained('categorias')
->restrictOnDelete();
$tabela->string('nome');
$tabela->string('slug')->unique();
$tabela->text('descricao')->nullable();
// Centavos como INTEIRO, não decimal. O casamento com a classe
// Dinheiro é direto, e não há conversão float no caminho —
// nenhum centavo se perde entre o banco e o PHP.
$tabela->unsignedInteger('preco_centavos');
$tabela->unsignedInteger('estoque')->default(0);
$tabela->boolean('ativo')->default(true);
$tabela->timestamps();
$tabela->softDeletes();
$tabela->index(['categoria_id', 'ativo']);
$tabela->index('preco_centavos');
});
// Busca textual do PostgreSQL — o motivo de ter escolhido pgsql.
// A coluna é GERADA: o banco a mantém sincronizada sozinho, sem
// trigger e sem risco de alguém esquecer de atualizar.
\DB::statement(<<<'SQL'
ALTER TABLE produtos
ADD COLUMN busca tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('portuguese', coalesce(nome, '')), 'A') ||
setweight(to_tsvector('portuguese', coalesce(descricao, '')), 'B')
) STORED
SQL);
// GIN é o índice para tsvector. Sem ele, a busca faz varredura
// completa e fica mais lenta que o LIKE que veio substituir.
\DB::statement('CREATE INDEX produtos_busca_idx ON produtos USING GIN (busca)');
}
public function down(): void
{
Schema::dropIfExists('produtos');
}
};
// ------------------- database/migrations/..._create_pedidos_table.php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('pedidos', function (Blueprint $tabela): void {
$tabela->id();
$tabela->foreignId('usuario_id')->constrained('usuarios')->restrictOnDelete();
$tabela->foreignId('endereco_id')->constrained('enderecos')->restrictOnDelete();
$tabela->string('codigo', 20)->unique();
$tabela->string('status', 20)->default('aguardando_pagamento');
$tabela->unsignedInteger('subtotal_centavos');
$tabela->unsignedInteger('frete_centavos')->default(0);
$tabela->unsignedInteger('desconto_centavos')->default(0);
$tabela->unsignedInteger('total_centavos');
$tabela->timestamp('pago_em')->nullable();
$tabela->timestamp('enviado_em')->nullable();
$tabela->timestamp('entregue_em')->nullable();
$tabela->timestamp('cancelado_em')->nullable();
$tabela->string('motivo_cancelamento')->nullable();
$tabela->timestamps();
$tabela->softDeletes();
$tabela->index(['usuario_id', 'created_at']);
$tabela->index(['status', 'created_at']);
});
Schema::create('itens_pedido', function (Blueprint $tabela): void {
$tabela->id();
// O item não existe sem o pedido: CASCADE é a escolha certa
// AQUI, e só aqui.
$tabela->foreignId('pedido_id')->constrained('pedidos')->cascadeOnDelete();
$tabela->foreignId('produto_id')->constrained('produtos')->restrictOnDelete();
// Nome e preço são COPIADOS no momento da compra. O catálogo
// muda; a nota fiscal, não. Sem esta cópia, renomear um
// produto reescreve o histórico de todos os pedidos.
$tabela->string('produto_nome');
$tabela->unsignedInteger('preco_unitario_centavos');
$tabela->unsignedInteger('quantidade');
$tabela->unsignedInteger('subtotal_centavos');
$tabela->timestamps();
$tabela->unique(['pedido_id', 'produto_id']);
});
// Trilha de auditoria das transições — é ela que alimenta a
// timeline do painel e responde "quem cancelou este pedido".
Schema::create('historico_de_status', function (Blueprint $tabela): void {
$tabela->id();
$tabela->foreignId('pedido_id')->constrained('pedidos')->cascadeOnDelete();
$tabela->foreignId('usuario_id')->nullable()->constrained('usuarios')->nullOnDelete();
$tabela->string('de', 20)->nullable();
$tabela->string('para', 20);
$tabela->string('observacao')->nullable();
$tabela->timestamps();
$tabela->index(['pedido_id', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('historico_de_status');
Schema::dropIfExists('itens_pedido');
Schema::dropIfExists('pedidos');
}
};
// ---------------------------------------- config/database.php (trecho)
declare(strict_types=1);
return [
'connections' => [
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE'),
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
'charset' => 'utf8',
'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'),
],
],
'redis' => [
'client' => env('REDIS_CLIENT', 'predis'),
'options' => [
// O prefixo evita colisão quando dois ambientes compartilham a
// mesma instância de Redis — situação comum em hospedagem
// barata, e origem de bugs difíceis de explicar.
'prefix' => env('REDIS_PREFIX', 'mercatto_' . env('APP_ENV', 'local') . '_'),
],
// Bancos separados por finalidade: `php artisan cache:clear` não
// pode apagar a fila nem derrubar as sessões de todo mundo.
'default' => ['host' => env('REDIS_HOST'), 'port' => env('REDIS_PORT', 6379), 'database' => 0],
'cache' => ['host' => env('REDIS_HOST'), 'port' => env('REDIS_PORT', 6379), 'database' => 1],
'sessao' => ['host' => env('REDIS_HOST'), 'port' => env('REDIS_PORT', 6379), 'database' => 2],
],
];
// ============ verificando ============
//
// php artisan migrate
// php artisan migrate:status
// php artisan db:show
// php artisan db:table produtos
//
// php artisan tinker
// >>> Cache::store('redis')->put('teste', 'ok', 10);
// >>> Cache::store('redis')->get('teste'); // "ok"
// >>> DB::select('select version()'); // confirma o PostgreSQL
//
// E a busca textual funcionando:
// >>> DB::select("select nome from produtos
// where busca @@ plainto_tsquery('portuguese', ?)", ['teclado']);
Exercício 2
Implemente Dinheiro e StatusDoPedido exatamente como no artigo. Escreva testes unitários Pest cobrindo todos os métodos, incluindo podeTransicionarPara() com todas as combinações de estados.
Ver resposta
✓ Resposta: A máquina de estados dentro do enum é o que impede um pedido de pular de "aguardando pagamento" direto para "entregue" — e o teste das 49 combinações é o que impede alguém de afrouxar a regra sem perceber. Repare que ele percorre o produto cartesiano em vez de listar casos à mão: escrever 49 asserções manualmente é erro garantido, e o laço ainda cobre automaticamente o dia em que um oitavo status for acrescentado. O teste de rótulo e cor tem a mesma função: um case novo sem braço no match quebra a suíte, não a produção.
<?php
// ---------------------------------------- app/Dominio/StatusDoPedido.php
declare(strict_types=1);
namespace App\Dominio;
enum StatusDoPedido: string
{
case AguardandoPagamento = 'aguardando_pagamento';
case Pago = 'pago';
case EmSeparacao = 'em_separacao';
case Enviado = 'enviado';
case Entregue = 'entregue';
case Cancelado = 'cancelado';
case Reembolsado = 'reembolsado';
public function rotulo(): string
{
return match ($this) {
self::AguardandoPagamento => 'Aguardando pagamento',
self::Pago => 'Pagamento confirmado',
self::EmSeparacao => 'Em separação',
self::Enviado => 'A caminho',
self::Entregue => 'Entregue',
self::Cancelado => 'Cancelado',
self::Reembolsado => 'Reembolsado',
};
}
public function cor(): string
{
return match ($this) {
self::AguardandoPagamento => 'amarelo',
self::Pago, self::EmSeparacao, self::Enviado => 'azul',
self::Entregue => 'verde',
self::Cancelado, self::Reembolsado => 'vermelho',
};
}
/**
* A MÁQUINA DE ESTADOS. Estar aqui, e não espalhada em ifs pelos
* controllers, é o que impede um pedido de pular de "aguardando
* pagamento" direto para "entregue" — e é o que torna a regra
* testável de uma vez só.
*
* @return list<self>
*/
public function proximosPossiveis(): array
{
return match ($this) {
self::AguardandoPagamento => [self::Pago, self::Cancelado],
self::Pago => [self::EmSeparacao, self::Cancelado, self::Reembolsado],
self::EmSeparacao => [self::Enviado, self::Cancelado],
self::Enviado => [self::Entregue],
// Estados finais: nenhuma saída. A lista vazia é a regra.
self::Entregue => [self::Reembolsado],
self::Cancelado => [],
self::Reembolsado => [],
};
}
public function podeTransicionarPara(self $destino): bool
{
return in_array($destino, $this->proximosPossiveis(), strict: true);
}
public function ehFinal(): bool
{
return $this->proximosPossiveis() === [];
}
public function podeSerCancelado(): bool
{
return $this->podeTransicionarPara(self::Cancelado);
}
/** Estados em que o estoque já foi reservado e precisa voltar. */
public function reservaEstoque(): bool
{
return in_array($this, [
self::AguardandoPagamento, self::Pago, self::EmSeparacao, self::Enviado,
], strict: true);
}
}
// ---------------------------------------- app/Dominio/Dinheiro.php
declare(strict_types=1);
namespace App\Dominio;
use InvalidArgumentException;
use JsonSerializable;
use Stringable;
final readonly class Dinheiro implements JsonSerializable, Stringable
{
public function __construct(public int $centavos)
{
if ($centavos < 0) {
throw new InvalidArgumentException('Dinheiro não pode ser negativo.');
}
}
public static function zero(): self
{
return new self(0);
}
public static function deReais(int|float|string $valor): self
{
if (is_string($valor)) {
$valor = (float) str_replace(',', '.', str_replace('.', '', $valor));
}
// round ANTES do cast: (int)(0.29 * 100) devolve 28.
return new self((int) round((float) $valor * 100));
}
public function somar(self $outro): self
{
return new self($this->centavos + $outro->centavos);
}
public function subtrair(self $outro): self
{
if ($outro->centavos > $this->centavos) {
throw new InvalidArgumentException('O resultado ficaria negativo.');
}
return new self($this->centavos - $outro->centavos);
}
public function multiplicar(int $quantidade): self
{
if ($quantidade < 0) {
throw new InvalidArgumentException('Quantidade negativa.');
}
return new self($this->centavos * $quantidade);
}
public function percentual(float $percentual): self
{
if ($percentual < 0 || $percentual > 100) {
throw new InvalidArgumentException("Percentual fora da faixa: {$percentual}");
}
return new self((int) round($this->centavos * $percentual / 100));
}
public function equals(self $outro): bool
{
return $this->centavos === $outro->centavos;
}
public function maiorQue(self $outro): bool
{
return $this->centavos > $outro->centavos;
}
public function ehZero(): bool
{
return $this->centavos === 0;
}
public function emReais(): float
{
return $this->centavos / 100;
}
public function formatado(): string
{
return 'R$ ' . number_format($this->centavos / 100, 2, ',', '.');
}
public function __toString(): string
{
return $this->formatado();
}
public function jsonSerialize(): array
{
return ['centavos' => $this->centavos, 'formatado' => $this->formatado()];
}
}
// ---------------------------------------- app/Casts/DinheiroCast.php
declare(strict_types=1);
namespace App\Casts;
use App\Dominio\Dinheiro;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
/** @implements CastsAttributes<Dinheiro, Dinheiro|int> */
class DinheiroCast implements CastsAttributes
{
public function get(Model $model, string $chave, mixed $valor, array $atributos): Dinheiro
{
return new Dinheiro((int) $valor);
}
public function set(Model $model, string $chave, mixed $valor, array $atributos): int
{
return $valor instanceof Dinheiro ? $valor->centavos : (int) $valor;
}
}
// ---------------------------------------- tests/Unit/StatusDoPedidoTest.php (Pest)
declare(strict_types=1);
use App\Dominio\StatusDoPedido;
describe('transições de status', function (): void {
/**
* O teste que o exercício pede: TODAS as combinações. São 7 × 7 = 49,
* e escrevê-las à mão seria erro garantido. O laço percorre o produto
* cartesiano e confere cada par contra a tabela de verdade.
*/
it('valida as 49 combinações contra a tabela de transições', function (): void {
$permitidas = [
'aguardando_pagamento' => ['pago', 'cancelado'],
'pago' => ['em_separacao', 'cancelado', 'reembolsado'],
'em_separacao' => ['enviado', 'cancelado'],
'enviado' => ['entregue'],
'entregue' => ['reembolsado'],
'cancelado' => [],
'reembolsado' => [],
];
foreach (StatusDoPedido::cases() as $origem) {
foreach (StatusDoPedido::cases() as $destino) {
$esperado = in_array($destino->value, $permitidas[$origem->value], true);
expect($origem->podeTransicionarPara($destino))
->toBe(
$esperado,
"{$origem->value} → {$destino->value} deveria ser "
. ($esperado ? 'permitido' : 'proibido'),
);
}
}
});
it('nunca permite um status transicionar para si mesmo', function (): void {
foreach (StatusDoPedido::cases() as $status) {
expect($status->podeTransicionarPara($status))->toBeFalse();
}
});
it('trata estados finais como sem saída', function (): void {
expect(StatusDoPedido::Cancelado->ehFinal())->toBeTrue()
->and(StatusDoPedido::Reembolsado->ehFinal())->toBeTrue()
->and(StatusDoPedido::Pago->ehFinal())->toBeFalse();
});
it('não deixa pular etapas', function (StatusDoPedido $origem, StatusDoPedido $destino): void {
expect($origem->podeTransicionarPara($destino))->toBeFalse();
})->with([
'aguardando → entregue' => [StatusDoPedido::AguardandoPagamento, StatusDoPedido::Entregue],
'aguardando → enviado' => [StatusDoPedido::AguardandoPagamento, StatusDoPedido::Enviado],
'pago → entregue' => [StatusDoPedido::Pago, StatusDoPedido::Entregue],
'cancelado → pago' => [StatusDoPedido::Cancelado, StatusDoPedido::Pago],
'entregue → enviado' => [StatusDoPedido::Entregue, StatusDoPedido::Enviado],
]);
it('só cancela enquanto não entregou', function (): void {
expect(StatusDoPedido::AguardandoPagamento->podeSerCancelado())->toBeTrue()
->and(StatusDoPedido::Pago->podeSerCancelado())->toBeTrue()
->and(StatusDoPedido::EmSeparacao->podeSerCancelado())->toBeTrue()
->and(StatusDoPedido::Enviado->podeSerCancelado())->toBeFalse()
->and(StatusDoPedido::Entregue->podeSerCancelado())->toBeFalse();
});
it('sabe quais estados seguram estoque', function (): void {
expect(StatusDoPedido::Pago->reservaEstoque())->toBeTrue()
->and(StatusDoPedido::Cancelado->reservaEstoque())->toBeFalse()
->and(StatusDoPedido::Entregue->reservaEstoque())->toBeFalse();
});
it('tem rótulo e cor para todos os casos', function (): void {
// Garante que nenhum match() ficou sem um braço — o dia em que
// alguém acrescentar um case, este teste quebra.
foreach (StatusDoPedido::cases() as $status) {
expect($status->rotulo())->not->toBeEmpty()
->and($status->cor())->not->toBeEmpty();
}
});
});
// ---------------------------------------- tests/Unit/DinheiroTest.php (Pest)
declare(strict_types=1);
use App\Dominio\Dinheiro;
it('converte reais em centavos', function (string $entrada, int $esperado): void {
expect(Dinheiro::deReais($entrada)->centavos)->toBe($esperado);
})->with([
['19,90', 1990],
['1.234,56', 123456],
['0,29', 29], // o caso que o cast direto erraria
['0', 0],
]);
it('soma sem alterar os operandos', function (): void {
$a = Dinheiro::deReais(10);
expect($a->somar(Dinheiro::deReais(5))->centavos)->toBe(1500)
->and($a->centavos)->toBe(1000);
});
it('recusa resultado negativo na subtração', function (): void {
Dinheiro::deReais(10)->subtrair(Dinheiro::deReais(20));
})->throws(InvalidArgumentException::class);
it('multiplica pela quantidade', function (): void {
expect(Dinheiro::deReais('19,90')->multiplicar(3)->centavos)->toBe(5970);
});
it('calcula percentual arredondando', function (): void {
expect(Dinheiro::deReais(100)->percentual(10)->centavos)->toBe(1000)
->and((new Dinheiro(333))->percentual(10)->centavos)->toBe(33);
});
it('recusa percentual fora da faixa', function (float $p): void {
Dinheiro::deReais(100)->percentual($p);
})->with([-1.0, 100.01, 1000.0])->throws(InvalidArgumentException::class);
it('formata em português', function (): void {
expect((new Dinheiro(123456))->formatado())->toBe('R$ 1.234,56')
->and((new Dinheiro(0))->formatado())->toBe('R$ 0,00');
});
Exercício 3
Implemente Carrinho e ItemDoCarrinho com testes: adicionar item novo, adicionar item existente (incrementa), atualizar quantidade, remover, calcular total com múltiplos itens, comportamento ao atualizar com quantidade zero.
Ver resposta
✓ Resposta: Duas convenções de e-commerce que o teste fixa como contrato: adicionar um produto que já está no carrinho soma a quantidade (não sobrescreve, não cria segunda linha), e atualizar para zero remove o item — é como o usuário diz "tira isto daqui". Note também que o item guarda uma fotografia do produto (id, nome, preço) em vez do model: o carrinho vai para a sessão, e serializar Eloquent para lá é caro e frágil. O teste de ida e volta em JSON é justamente o que sustenta o próximo item.
<?php
// ---------------------------------------- app/Dominio/ItemDoCarrinho.php
declare(strict_types=1);
namespace App\Dominio;
use InvalidArgumentException;
use JsonSerializable;
/**
* O item guarda uma FOTOGRAFIA do produto no momento em que entrou no
* carrinho — id, nome e preço. Não guarda o model: o carrinho vive na
* sessão, e serializar Eloquent para lá é caro e frágil.
*/
final readonly class ItemDoCarrinho implements JsonSerializable
{
public function __construct(
public int $produtoId,
public string $nome,
public Dinheiro $precoUnitario,
public int $quantidade,
public int $estoqueDisponivel,
) {
if ($quantidade < 1) {
throw new InvalidArgumentException('A quantidade mínima é 1.');
}
}
public function subtotal(): Dinheiro
{
return $this->precoUnitario->multiplicar($this->quantidade);
}
/** Imutável: "mudar" a quantidade devolve um item novo. */
public function comQuantidade(int $quantidade): self
{
return new self(
$this->produtoId,
$this->nome,
$this->precoUnitario,
$quantidade,
$this->estoqueDisponivel,
);
}
public function somarQuantidade(int $quantidade): self
{
return $this->comQuantidade($this->quantidade + $quantidade);
}
public function temEstoque(): bool
{
return $this->estoqueDisponivel >= $this->quantidade;
}
/** @return array<string, mixed> */
public function jsonSerialize(): array
{
return [
'produto_id' => $this->produtoId,
'nome' => $this->nome,
'preco_centavos' => $this->precoUnitario->centavos,
'quantidade' => $this->quantidade,
'estoque_disponivel' => $this->estoqueDisponivel,
];
}
/** @param array<string, mixed> $dados */
public static function deArray(array $dados): self
{
return new self(
(int) $dados['produto_id'],
(string) $dados['nome'],
new Dinheiro((int) $dados['preco_centavos']),
(int) $dados['quantidade'],
(int) ($dados['estoque_disponivel'] ?? 0),
);
}
}
// ---------------------------------------- app/Dominio/Carrinho.php
declare(strict_types=1);
namespace App\Dominio;
use Countable;
use InvalidArgumentException;
use JsonSerializable;
final class Carrinho implements Countable, JsonSerializable
{
/** @var array<int, ItemDoCarrinho> produtoId => item */
private array $itens = [];
/** @param list<ItemDoCarrinho> $itens */
public function __construct(array $itens = [])
{
foreach ($itens as $item) {
$this->itens[$item->produtoId] = $item;
}
}
/**
* Adicionar produto que já está no carrinho SOMA a quantidade, em vez
* de sobrescrever. É o que qualquer loja faz — e o comportamento que
* o exercício pede explicitamente.
*/
public function adicionar(ItemDoCarrinho $item): void
{
$existente = $this->itens[$item->produtoId] ?? null;
$novo = $existente !== null
? $existente->somarQuantidade($item->quantidade)
: $item;
if ($novo->quantidade > $novo->estoqueDisponivel) {
throw new InvalidArgumentException(sprintf(
'Estoque insuficiente de "%s": disponível %d, pedido %d.',
$novo->nome,
$novo->estoqueDisponivel,
$novo->quantidade,
));
}
$this->itens[$item->produtoId] = $novo;
}
/**
* Atualizar para ZERO remove o item. É a convenção que todo carrinho
* de e-commerce adota — o campo de quantidade zerado é como o usuário
* diz "tira isto daqui".
*/
public function atualizarQuantidade(int $produtoId, int $quantidade): void
{
$item = $this->itens[$produtoId] ?? throw new InvalidArgumentException(
"Produto {$produtoId} não está no carrinho."
);
if ($quantidade <= 0) {
$this->remover($produtoId);
return;
}
if ($quantidade > $item->estoqueDisponivel) {
throw new InvalidArgumentException(sprintf(
'Estoque insuficiente de "%s": disponível %d.',
$item->nome,
$item->estoqueDisponivel,
));
}
$this->itens[$produtoId] = $item->comQuantidade($quantidade);
}
public function remover(int $produtoId): void
{
unset($this->itens[$produtoId]);
}
public function limpar(): void
{
$this->itens = [];
}
public function tem(int $produtoId): bool
{
return isset($this->itens[$produtoId]);
}
/** @return list<ItemDoCarrinho> */
public function itens(): array
{
return array_values($this->itens);
}
public function total(): Dinheiro
{
return array_reduce(
$this->itens,
static fn (Dinheiro $soma, ItemDoCarrinho $item): Dinheiro
=> $soma->somar($item->subtotal()),
Dinheiro::zero(),
);
}
/** Quantidade de PEÇAS, não de linhas — é o número do balãozinho. */
public function totalDeItens(): int
{
return array_sum(array_map(
static fn (ItemDoCarrinho $item): int => $item->quantidade,
$this->itens,
));
}
/** count($carrinho) devolve o número de LINHAS distintas. */
public function count(): int
{
return count($this->itens);
}
public function estaVazio(): bool
{
return $this->itens === [];
}
/** @return list<ItemDoCarrinho> os que perderam estoque desde que entraram */
public function itensSemEstoque(): array
{
return array_values(array_filter(
$this->itens,
static fn (ItemDoCarrinho $item): bool => !$item->temEstoque(),
));
}
public function jsonSerialize(): array
{
return ['itens' => array_map(
static fn (ItemDoCarrinho $item): array => $item->jsonSerialize(),
array_values($this->itens),
)];
}
/** @param array<string, mixed> $dados */
public static function deArray(array $dados): self
{
return new self(array_map(
static fn (array $item): ItemDoCarrinho => ItemDoCarrinho::deArray($item),
$dados['itens'] ?? [],
));
}
}
// ---------------------------------------- tests/Unit/CarrinhoTest.php (Pest)
declare(strict_types=1);
use App\Dominio\Carrinho;
use App\Dominio\Dinheiro;
use App\Dominio\ItemDoCarrinho;
function item(int $id = 1, int $quantidade = 1, int $precoCentavos = 1000, int $estoque = 10): ItemDoCarrinho
{
return new ItemDoCarrinho($id, "Produto {$id}", new Dinheiro($precoCentavos), $quantidade, $estoque);
}
it('começa vazio', function (): void {
$carrinho = new Carrinho();
expect($carrinho->estaVazio())->toBeTrue()
->and($carrinho)->toHaveCount(0)
->and($carrinho->total()->centavos)->toBe(0);
});
it('adiciona um item novo', function (): void {
$carrinho = new Carrinho();
$carrinho->adicionar(item(1, quantidade: 2, precoCentavos: 1500));
expect($carrinho)->toHaveCount(1)
->and($carrinho->totalDeItens())->toBe(2)
->and($carrinho->total()->centavos)->toBe(3000);
});
it('soma a quantidade ao adicionar o mesmo produto', function (): void {
$carrinho = new Carrinho();
$carrinho->adicionar(item(1, quantidade: 2));
$carrinho->adicionar(item(1, quantidade: 3));
// Uma LINHA, cinco peças — não duas linhas nem quantidade 3.
expect($carrinho)->toHaveCount(1)
->and($carrinho->itens()[0]->quantidade)->toBe(5);
});
it('atualiza a quantidade', function (): void {
$carrinho = new Carrinho();
$carrinho->adicionar(item(1, quantidade: 2, precoCentavos: 1000));
$carrinho->atualizarQuantidade(1, 5);
expect($carrinho->itens()[0]->quantidade)->toBe(5)
->and($carrinho->total()->centavos)->toBe(5000);
});
it('remove o item ao atualizar para zero', function (): void {
$carrinho = new Carrinho();
$carrinho->adicionar(item(1));
$carrinho->atualizarQuantidade(1, 0);
expect($carrinho->estaVazio())->toBeTrue();
});
it('remove o item com quantidade negativa também', function (): void {
$carrinho = new Carrinho();
$carrinho->adicionar(item(1));
$carrinho->atualizarQuantidade(1, -3);
expect($carrinho->estaVazio())->toBeTrue();
});
it('remove um item específico', function (): void {
$carrinho = new Carrinho();
$carrinho->adicionar(item(1));
$carrinho->adicionar(item(2));
$carrinho->remover(1);
expect($carrinho)->toHaveCount(1)
->and($carrinho->tem(1))->toBeFalse()
->and($carrinho->tem(2))->toBeTrue();
});
it('soma o total com vários itens', function (): void {
$carrinho = new Carrinho();
$carrinho->adicionar(item(1, quantidade: 2, precoCentavos: 1990)); // 39,80
$carrinho->adicionar(item(2, quantidade: 1, precoCentavos: 550)); // 5,50
$carrinho->adicionar(item(3, quantidade: 3, precoCentavos: 1000)); // 30,00
expect($carrinho->total()->centavos)->toBe(7530)
->and($carrinho->total()->formatado())->toBe('R$ 75,30')
->and($carrinho->totalDeItens())->toBe(6)
->and($carrinho)->toHaveCount(3);
});
it('recusa quantidade acima do estoque', function (): void {
$carrinho = new Carrinho();
$carrinho->adicionar(item(1, quantidade: 1, estoque: 3));
$carrinho->atualizarQuantidade(1, 10);
})->throws(InvalidArgumentException::class, 'Estoque insuficiente');
it('recusa a soma que ultrapassa o estoque', function (): void {
$carrinho = new Carrinho();
$carrinho->adicionar(item(1, quantidade: 2, estoque: 3));
$carrinho->adicionar(item(1, quantidade: 2, estoque: 3)); // 2 + 2 > 3
})->throws(InvalidArgumentException::class);
it('recusa atualizar produto que não está no carrinho', function (): void {
(new Carrinho())->atualizarQuantidade(99, 1);
})->throws(InvalidArgumentException::class);
it('recusa item com quantidade menor que 1', function (): void {
item(1, quantidade: 0);
})->throws(InvalidArgumentException::class);
it('sobrevive à ida e volta em JSON', function (): void {
$carrinho = new Carrinho();
$carrinho->adicionar(item(1, quantidade: 2, precoCentavos: 1990));
$carrinho->adicionar(item(2, quantidade: 1, precoCentavos: 550));
$reconstruido = Carrinho::deArray(
json_decode(json_encode($carrinho, JSON_THROW_ON_ERROR), true, flags: JSON_THROW_ON_ERROR)
);
// A propriedade que o item 5 vai exigir: o carrinho precisa
// atravessar a sessão sem perder nada.
expect($reconstruido->total()->centavos)->toBe($carrinho->total()->centavos)
->and($reconstruido)->toHaveCount(2);
});
Exercício 4
Escreva os Service Providers do projeto: bind de todos os repositórios, configuração do Redis, configuração dos canais de log.
Ver resposta
✓ Resposta: Duas ideias por trás da divisão. A primeira é o decorator montado no provider: o repositório cacheado embrulha o Eloquent em produção e some nos testes — assim o teste verifica a consulta, não o cache, e nenhuma outra classe fica sabendo. A segunda são as travas de Model::: preventLazyLoading e preventSilentlyDiscardingAttributes transformam em exceção dois erros que normalmente só aparecem como lentidão ou campo que "não salva". E separe os bancos do Redis por finalidade — um cache:clear de rotina não pode apagar o carrinho de quem está comprando.
<?php
// ---------------------------------------- app/Providers/RepositorioServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use App\Repositorios\CarrinhoRepositorio;
use App\Repositorios\CarrinhoSessaoRepositorio;
use App\Repositorios\CategoriaRepositorio;
use App\Repositorios\CategoriaRepositorioEloquent;
use App\Repositorios\PedidoRepositorio;
use App\Repositorios\PedidoRepositorioEloquent;
use App\Repositorios\ProdutoRepositorio;
use App\Repositorios\ProdutoRepositorioCacheado;
use App\Repositorios\ProdutoRepositorioEloquent;
use Illuminate\Contracts\Foundation\CachesConfiguration;
use Illuminate\Support\ServiceProvider;
class RepositorioServiceProvider extends ServiceProvider
{
/**
* O atalho declarativo: o container lê este array e faz o binding
* sozinho. Vale para o que não precisa de decisão.
*
* @var array<class-string, class-string>
*/
public array $bindings = [
CategoriaRepositorio::class => CategoriaRepositorioEloquent::class,
PedidoRepositorio::class => PedidoRepositorioEloquent::class,
CarrinhoRepositorio::class => CarrinhoSessaoRepositorio::class,
];
public function register(): void
{
// O de produtos precisa de decisão: em produção, o repositório
// Eloquent é EMBRULHADO pelo cacheado (decorator). Em teste, não —
// senão o teste passa a verificar o cache, não a consulta.
$this->app->singleton(ProdutoRepositorio::class, function ($app): ProdutoRepositorio {
$base = new ProdutoRepositorioEloquent();
return $app->environment('testing')
? $base
: new ProdutoRepositorioCacheado($base, $app->make('cache.store'));
});
}
/**
* A lista de serviços do provider adiado. O container só carrega este
* arquivo quando alguém pedir um deles — em requisição que não toca em
* repositório, o provider nem é instanciado.
*
* @return list<class-string>
*/
public function provides(): array
{
return [
ProdutoRepositorio::class,
CategoriaRepositorio::class,
PedidoRepositorio::class,
CarrinhoRepositorio::class,
];
}
}
// ---------------------------------------- app/Providers/DominioServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use App\Dominio\StatusDoPedido;
use App\Models\Pedido;
use App\Models\Produto;
use App\Observers\PedidoObserver;
use App\Observers\ProdutoObserver;
use App\Policies\PedidoPolicy;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\ServiceProvider;
class DominioServiceProvider extends ServiceProvider
{
public function boot(): void
{
// ---- observers ----
Produto::observe(ProdutoObserver::class);
Pedido::observe(PedidoObserver::class);
// ---- policies ----
Gate::policy(Pedido::class, PedidoPolicy::class);
Gate::before(fn ($usuario) => $usuario->hasRole('admin') ? true : null);
// ---- regras de validação do domínio ----
Validator::extend('transicao_valida', function ($atributo, $valor, $parametros): bool {
$atual = StatusDoPedido::tryFrom($parametros[0] ?? '');
$novo = StatusDoPedido::tryFrom((string) $valor);
return $atual !== null && $novo !== null && $atual->podeTransicionarPara($novo);
}, 'Esta transição de status não é permitida.');
// ---- diretivas Blade do projeto ----
Blade::directive('dinheiro', function (string $expressao): string {
return "<?php echo e(\$__env->make('parciais.dinheiro', ['valor' => {$expressao}])->render()); ?>";
});
// ---- proteções em desenvolvimento ----
if ($this->app->environment('local')) {
// Transforma N+1 em exceção, em vez de lentidão silenciosa.
Model::preventLazyLoading();
// E acessar atributo que não veio no SELECT também.
Model::preventAccessingMissingAttributes();
}
// Vale em TODO ambiente: `Produto::create($request->all())` com
// um campo a mais no POST passa a explodir em vez de ser ignorado
// em silêncio.
Model::preventSilentlyDiscardingAttributes();
// Só o que o servidor aguenta. Sem isso, um único
// `Pedido::all()` num banco grande derruba o processo por memória.
Model::shouldBeStrict(!$this->app->isProduction());
}
}
// ---------------------------------------- app/Providers/CacheServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\ServiceProvider;
class CacheServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Um serviço nomeado para o carrinho: TTL longo, banco próprio.
// Separar por finalidade impede que um `cache:clear` de rotina
// apague o carrinho de todo mundo que está comprando.
$this->app->singleton('cache.carrinho', fn () => cache()->store('redis'));
if ($this->app->environment('local')) {
// Todo comando enviado ao Redis no log — o equivalente do
// DB::listen para o cache.
Redis::enableEvents();
}
}
}
// ---------------------------------------- app/Providers/LogServiceProvider.php
declare(strict_types=1);
namespace App\Providers;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
use Monolog\Processor\PsrLogMessageProcessor;
class LogServiceProvider extends ServiceProvider
{
public function boot(): void
{
// Um id por requisição, em TODA linha de log. É o que permite
// reconstruir o que aconteceu num acesso específico quando há 40
// requisições por segundo misturadas no arquivo.
$requestId = request()->header('X-Request-Id') ?? (string) \Illuminate\Support\Str::uuid();
foreach (['single', 'daily', 'pedidos'] as $canal) {
$logger = Log::channel($canal)->getLogger();
$logger->pushProcessor(new PsrLogMessageProcessor());
$logger->pushProcessor(static function (array $registro) use ($requestId): array {
$registro['extra']['request_id'] = $requestId;
$registro['extra']['usuario_id'] = auth()->id();
return $registro;
});
}
}
}
// ---------------------------------------- config/logging.php (trecho)
declare(strict_types=1);
return [
'channels' => [
// Canal dedicado ao domínio: o que aconteceu com cada pedido não
// se mistura com erro de dependência nem com log de framework.
'pedidos' => [
'driver' => 'daily',
'path' => storage_path('logs/pedidos.log'),
'level' => 'info',
'days' => 90, // dado de negócio: retenção maior
],
'sql' => [
'driver' => 'single',
'path' => storage_path('logs/sql.log'),
'level' => 'debug',
],
// Pilha de produção: arquivo + stderr (para o orquestrador) +
// Slack só para o que é crítico.
'producao' => [
'driver' => 'stack',
'channels' => ['daily', 'stderr', 'slack'],
'ignore_exceptions' => false,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Mercatto',
'emoji' => ':boom:',
// critical, não error: alerta que dispara toda hora é alerta
// que ninguém lê.
'level' => 'critical',
],
],
];
// ---------------------------------------- bootstrap/providers.php
declare(strict_types=1);
return [
App\Providers\AppServiceProvider::class,
App\Providers\DominioServiceProvider::class,
App\Providers\RepositorioServiceProvider::class,
App\Providers\CacheServiceProvider::class,
App\Providers\LogServiceProvider::class,
];
Exercício 5
Desafio: implemente CarrinhoService que persiste o Carrinho na sessão do Laravel (serializa para JSON, desserializa ao carregar). Garanta que o carrinho sobrevive entre requisições e que itens com estoque zerado são removidos automaticamente ao carregar.
Ver resposta
✓ Resposta: Duas decisões carregam este item. JSON em vez de serialize(): o formato do serialize() guarda o nome da classe, e renomear ItemDoCarrinho quebraria o carrinho de todo mundo que estava comprando durante o deploy. E a reconciliação no carregamento — o carrinho pode ter ficado dias parado na sessão, então produto removido, esgotado ou com preço novo precisa ser tratado ali, com aviso ao usuário, e não descoberto no checkout. O preço que vale é sempre o atual: honrar o antigo é vender por um valor que a loja não pratica mais.
<?php
// ---------------------------------------- app/Repositorios/CarrinhoRepositorio.php
declare(strict_types=1);
namespace App\Repositorios;
use App\Dominio\Carrinho;
interface CarrinhoRepositorio
{
public function obter(): Carrinho;
public function salvar(Carrinho $carrinho): void;
public function limpar(): void;
}
// ---------------------------------------- app/Repositorios/CarrinhoSessaoRepositorio.php
declare(strict_types=1);
namespace App\Repositorios;
use App\Dominio\Carrinho;
use Illuminate\Contracts\Session\Session;
class CarrinhoSessaoRepositorio implements CarrinhoRepositorio
{
private const CHAVE = 'carrinho';
public function __construct(private readonly Session $sessao) {}
public function obter(): Carrinho
{
// JSON, não serialize(): o formato do serialize() carrega o nome
// da CLASSE, e renomear ou mover ItemDoCarrinho quebraria todo
// carrinho salvo na sessão de quem estava comprando durante o
// deploy. JSON é dado puro e sobrevive a refatoração.
$dados = $this->sessao->get(self::CHAVE);
if (!is_array($dados)) {
return new Carrinho();
}
try {
return Carrinho::deArray($dados);
} catch (\Throwable) {
// Dado corrompido (sessão de uma versão antiga do formato):
// melhor um carrinho vazio que uma tela de erro no meio da
// compra.
$this->limpar();
return new Carrinho();
}
}
public function salvar(Carrinho $carrinho): void
{
$this->sessao->put(self::CHAVE, $carrinho->jsonSerialize());
}
public function limpar(): void
{
$this->sessao->forget(self::CHAVE);
}
}
// ---------------------------------------- app/Services/CarrinhoService.php
declare(strict_types=1);
namespace App\Services;
use App\Dominio\Carrinho;
use App\Dominio\Dinheiro;
use App\Dominio\ItemDoCarrinho;
use App\Models\Produto;
use App\Repositorios\CarrinhoRepositorio;
use RuntimeException;
class CarrinhoService
{
private const QUANTIDADE_MAXIMA_POR_ITEM = 20;
public function __construct(private readonly CarrinhoRepositorio $repositorio) {}
/**
* Carrega o carrinho e o RECONCILIA com o catálogo atual.
*
* O carrinho pode ter ficado dias parado na sessão. Nesse intervalo,
* produto foi desativado, preço mudou, estoque acabou. Servir o
* carrinho sem reconciliar é deixar o usuário chegar ao checkout e só
* então descobrir — ou, pior, comprar pelo preço antigo.
*
* @return array{carrinho: Carrinho, avisos: list<string>}
*/
public function carregar(): array
{
$carrinho = $this->repositorio->obter();
if ($carrinho->estaVazio()) {
return ['carrinho' => $carrinho, 'avisos' => []];
}
$ids = array_map(
static fn (ItemDoCarrinho $item): int => $item->produtoId,
$carrinho->itens(),
);
// Uma consulta para todos os produtos do carrinho.
$produtos = Produto::query()
->whereIn('id', $ids)
->where('ativo', true)
->get()
->keyBy('id');
$reconciliado = new Carrinho();
$avisos = [];
foreach ($carrinho->itens() as $item) {
$produto = $produtos->get($item->produtoId);
// 1. Saiu do catálogo.
if ($produto === null) {
$avisos[] = "\"{$item->nome}\" não está mais disponível e foi removido.";
continue;
}
// 2. Estoque zerado.
if ($produto->estoque === 0) {
$avisos[] = "\"{$produto->nome}\" está esgotado e foi removido.";
continue;
}
// 3. Estoque menor que o pedido: ajusta em vez de remover.
$quantidade = min($item->quantidade, $produto->estoque);
if ($quantidade < $item->quantidade) {
$avisos[] = sprintf(
'A quantidade de "%s" foi ajustada para %d (é o que resta em estoque).',
$produto->nome,
$quantidade,
);
}
// 4. Preço mudou — e o preço que vale é sempre o ATUAL. Manter
// o antigo seria vender por um valor que a loja não pratica
// mais; o aviso existe para o usuário não ser surpreendido no
// checkout.
$precoAtual = new Dinheiro($produto->preco_centavos);
if (!$precoAtual->equals($item->precoUnitario)) {
$avisos[] = sprintf(
'O preço de "%s" mudou de %s para %s.',
$produto->nome,
$item->precoUnitario->formatado(),
$precoAtual->formatado(),
);
}
$reconciliado->adicionar(new ItemDoCarrinho(
produtoId: $produto->id,
nome: $produto->nome,
precoUnitario: $precoAtual,
quantidade: $quantidade,
estoqueDisponivel: $produto->estoque,
));
}
// Só grava se algo mudou — escrita de sessão a cada leitura é
// desperdício e ainda invalida cache de página.
if ($avisos !== []) {
$this->repositorio->salvar($reconciliado);
}
return ['carrinho' => $reconciliado, 'avisos' => $avisos];
}
public function adicionar(int $produtoId, int $quantidade = 1): Carrinho
{
$produto = Produto::query()
->where('ativo', true)
->find($produtoId)
?? throw new RuntimeException('Produto indisponível.');
if ($produto->estoque < $quantidade) {
throw new RuntimeException(
"Estoque insuficiente: restam {$produto->estoque} unidade(s)."
);
}
['carrinho' => $carrinho] = $this->carregar();
$jaTem = $carrinho->tem($produtoId)
? collect($carrinho->itens())->firstWhere('produtoId', $produtoId)->quantidade
: 0;
// Limite por item: sem ele, alguém compra o estoque inteiro de um
// produto em promoção numa requisição só.
if ($jaTem + $quantidade > self::QUANTIDADE_MAXIMA_POR_ITEM) {
throw new RuntimeException(
'Limite de ' . self::QUANTIDADE_MAXIMA_POR_ITEM . ' unidades por produto.'
);
}
$carrinho->adicionar(new ItemDoCarrinho(
produtoId: $produto->id,
nome: $produto->nome,
precoUnitario: new Dinheiro($produto->preco_centavos),
quantidade: $quantidade,
estoqueDisponivel: $produto->estoque,
));
$this->repositorio->salvar($carrinho);
return $carrinho;
}
public function atualizar(int $produtoId, int $quantidade): Carrinho
{
['carrinho' => $carrinho] = $this->carregar();
$carrinho->atualizarQuantidade($produtoId, $quantidade);
$this->repositorio->salvar($carrinho);
return $carrinho;
}
public function remover(int $produtoId): Carrinho
{
['carrinho' => $carrinho] = $this->carregar();
$carrinho->remover($produtoId);
$this->repositorio->salvar($carrinho);
return $carrinho;
}
public function limpar(): void
{
$this->repositorio->limpar();
}
}
// ---------------------------------------- app/Http/Middleware/CompartilharCarrinho.php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Services\CarrinhoService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class CompartilharCarrinho
{
public function __construct(private readonly CarrinhoService $carrinho) {}
public function handle(Request $request, Closure $proximo): Response
{
// O balãozinho do cabeçalho aparece em toda página; sem isto, cada
// controller teria de passar o carrinho para a view.
View::composer('parciais.cabecalho', function ($view): void {
['carrinho' => $carrinho] = $this->carrinho->carregar();
$view->with('carrinhoResumo', [
'itens' => $carrinho->totalDeItens(),
'total' => $carrinho->total(),
]);
});
return $proximo($request);
}
}
// ---------------------------------------- tests/Feature/CarrinhoServiceTest.php (Pest)
declare(strict_types=1);
use App\Models\Produto;
use App\Services\CarrinhoService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('sobrevive entre requisições', function (): void {
$produto = Produto::factory()->create(['estoque' => 10, 'preco_centavos' => 1990]);
// Requisição 1: adiciona.
$this->post("/carrinho/{$produto->id}", ['quantidade' => 2])->assertRedirect();
// Requisição 2: outra chamada HTTP, mesma sessão de teste.
$this->get('/carrinho')
->assertOk()
->assertSee($produto->nome)
->assertSee('R$ 39,80');
});
it('remove ao carregar o produto que ficou sem estoque', function (): void {
$produto = Produto::factory()->create(['estoque' => 5, 'nome' => 'Caneca']);
$this->post("/carrinho/{$produto->id}", ['quantidade' => 2]);
// Alguém comprou tudo enquanto o carrinho estava parado.
$produto->update(['estoque' => 0]);
['carrinho' => $carrinho, 'avisos' => $avisos] = app(CarrinhoService::class)->carregar();
expect($carrinho->estaVazio())->toBeTrue()
->and($avisos)->toHaveCount(1)
->and($avisos[0])->toContain('esgotado');
});
it('ajusta a quantidade ao estoque que restou', function (): void {
$produto = Produto::factory()->create(['estoque' => 10]);
$this->post("/carrinho/{$produto->id}", ['quantidade' => 8]);
$produto->update(['estoque' => 3]);
['carrinho' => $carrinho, 'avisos' => $avisos] = app(CarrinhoService::class)->carregar();
expect($carrinho->itens()[0]->quantidade)->toBe(3)
->and($avisos[0])->toContain('ajustada');
});
it('remove o produto desativado', function (): void {
$produto = Produto::factory()->create(['estoque' => 10]);
$this->post("/carrinho/{$produto->id}", ['quantidade' => 1]);
$produto->update(['ativo' => false]);
['carrinho' => $carrinho] = app(CarrinhoService::class)->carregar();
expect($carrinho->estaVazio())->toBeTrue();
});
it('usa o preço atual e avisa da mudança', function (): void {
$produto = Produto::factory()->create(['estoque' => 10, 'preco_centavos' => 1000]);
$this->post("/carrinho/{$produto->id}", ['quantidade' => 1]);
$produto->update(['preco_centavos' => 1500]);
['carrinho' => $carrinho, 'avisos' => $avisos] = app(CarrinhoService::class)->carregar();
// O preço que vale é o de agora — vender pelo antigo seria praticar
// um valor que a loja não oferece mais.
expect($carrinho->total()->centavos)->toBe(1500)
->and($avisos[0])->toContain('preço');
});
it('respeita o limite por produto', function (): void {
$produto = Produto::factory()->create(['estoque' => 100]);
app(CarrinhoService::class)->adicionar($produto->id, 20);
app(CarrinhoService::class)->adicionar($produto->id, 1);
})->throws(RuntimeException::class, 'Limite de 20');
it('não quebra com sessão corrompida', function (): void {
session(['carrinho' => 'isto não é um carrinho']);
['carrinho' => $carrinho] = app(CarrinhoService::class)->carregar();
// Carrinho vazio é melhor que tela de erro no meio da compra.
expect($carrinho->estaVazio())->toBeTrue();
});