PHP

Artigo 50 — Projeto Final Parte 1: Planejamento e Arquitetura Já leu

9 min de leitura

Artigo 50 — Projeto Final Parte 1: Planejamento e Arquitetura
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 é

Artigo 50 — Projeto Final Parte 1: Planejamento e Arquitetura

Módulo 11 · Semana 50 · Nível: Avançado


Introdução

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 + SQLite in-memory Velocidade e isolamento
Deploy Docker + GitHub Actions Reprodutibilidade e automação

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);
    }

    public function incrementar(int $qtd = 1): self
    {
        $novo = clone $this;
        $novo->quantidade += $qtd;
        return $novo;
    }

    public function comQuantidade(int $quantidade): self
    {
        $novo = clone $this;
        $novo->quantidade = $quantidade;
        return $novo;
    }
}

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; }
}

Exercício da semana

  1. Configure o projeto Laravel com PostgreSQL, Redis e as migrations do schema acima. Execute php artisan migrate com sucesso.

  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.

  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.

  4. Escreva os Service Providers do projeto: bind de todos os repositórios, configuração do Redis, configuração dos canais de log.

  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.


Referências

  • 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

Comentários

Mais em PHP

Design Patterns: Singleton, Factory e Builder
Design Patterns: Singleton, Factory e Builder

Design Patterns (Padrões de Projeto) são soluções codificadas para problemas...

Artigo 49 — Performance e Otimização
Artigo 49 — Performance e Otimização

Performance não é premature optimization — é entender onde estão os gargalos...

Artigo 44 — Autenticação e Autorização no Laravel
Artigo 44 — Autenticação e Autorização no Laravel

Autenticação responde "quem é você?" — verifica identidade. Autorização respo...