PHP

Artigo 51 — Projeto Final Parte 2: Implementação Já leu

10 min de leitura

Artigo 51 — Projeto Final Parte 2: Implementação
Com o planejamento sólido do artigo anterior, agora implementamos o sistema camada por camada. A ordem importa: domínio primeiro, infraestrutura depois, HTTP

Artigo 51 — Projeto Final Parte 2: Implementação

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


Introdução

Com o planejamento sólido do artigo anterior, agora implementamos o sistema camada por camada. A ordem importa: domínio primeiro, infraestrutura depois, HTTP por último. Isso garante que a lógica central nunca depende de detalhes de implementação.


Models Eloquent

<?php
declare(strict_types=1);

namespace App\Models;

use App\Domain\Pedido\StatusDoPedido;
use App\Domain\Produto\Dinheiro;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;

class Produto extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'categoria_id', 'nome', 'slug', 'descricao',
        'preco', 'preco_promocional', 'estoque', 'ativo', 'atributos', 'imagens',
    ];

    protected $casts = [
        'preco'             => 'decimal:2',
        'preco_promocional' => 'decimal:2',
        'estoque'           => 'integer',
        'ativo'             => 'boolean',
        'atributos'         => 'array',
        'imagens'           => 'array',
    ];

    protected static function booted(): void
    {
        static::creating(fn(self $p) => $p->slug ??= Str::slug($p->nome));
        static::updating(fn(self $p) => $p->atualizado_em = now());
    }

    public function precoAtual(): Dinheiro
    {
        $valor = $this->preco_promocional ?? $this->preco;
        return Dinheiro::dereais((float) $valor);
    }

    public function temPromocao(): bool
    {
        return $this->preco_promocional !== null
            && (float) $this->preco_promocional < (float) $this->preco;
    }

    public function estaDisponivel(): bool
    {
        return $this->ativo && $this->estoque > 0 && $this->deleted_at === null;
    }

    public function scopeDisponivel($query)
    {
        return $query->where('ativo', true)->where('estoque', '>', 0);
    }

    public function scopeDaCategoria($query, int $categoriaId)
    {
        return $query->where('categoria_id', $categoriaId);
    }

    public function categoria(): BelongsTo
    {
        return $this->belongsTo(Categoria::class);
    }

    public function itensDePedido(): HasMany
    {
        return $this->hasMany(PedidoItem::class);
    }
}

class Pedido extends Model
{
    protected $fillable = [
        'usuario_id', 'endereco_id', 'status',
        'subtotal', 'desconto', 'frete', 'total', 'observacoes',
    ];

    protected $casts = [
        'status'    => StatusDoPedido::class, // cast para Enum nativo
        'subtotal'  => 'decimal:2',
        'desconto'  => 'decimal:2',
        'frete'     => 'decimal:2',
        'total'     => 'decimal:2',
    ];

    public function podeSerCancelado(): bool
    {
        return $this->status->podeTransicionarPara(StatusDoPedido::Cancelado);
    }

    public function transicionarPara(StatusDoPedido $novoStatus): void
    {
        if (!$this->status->podeTransicionarPara($novoStatus)) {
            throw new \DomainException(
                "Não é possível ir de {$this->status->label()} para {$novoStatus->label()}."
            );
        }

        $this->update([
            'status'        => $novoStatus,
            'atualizado_em' => now(),
        ]);
    }

    public function usuario(): BelongsTo { return $this->belongsTo(User::class); }
    public function endereco(): BelongsTo { return $this->belongsTo(Endereco::class); }
    public function itens(): HasMany     { return $this->hasMany(PedidoItem::class); }
}

PedidoService — orquestrando o checkout

<?php
declare(strict_types=1);

namespace App\Services;

use App\Domain\Carrinho\Carrinho;
use App\Domain\Pedido\StatusDoPedido;
use App\Domain\Produto\Dinheiro;
use App\Jobs\ProcessarPedido;
use App\Models\Endereco;
use App\Models\Pedido;
use App\Models\PedidoItem;
use App\Models\Produto;
use App\Models\User;
use Illuminate\Support\Facades\DB;

final class PedidoService
{
    private const FRETE_GRATIS_ACIMA = 20000; // R$ 200,00 em centavos
    private const FRETE_PADRAO       = 1990;  // R$ 19,90 em centavos

    public function criarDesdoCarrinho(
        Carrinho $carrinho,
        User     $usuario,
        Endereco $endereco,
        ?string  $observacoes = null,
    ): Pedido {
        if ($carrinho->estaVazio()) {
            throw new \DomainException('O carrinho está vazio.');
        }

        // Valida estoque de todos os itens antes de começar a transação
        foreach ($carrinho->itens() as $item) {
            $produto = Produto::findOrFail($item->produtoId);

            if (!$produto->estaDisponivel()) {
                throw new \DomainException(
                    "O produto \"{$produto->nome}\" não está mais disponível."
                );
            }

            if ($produto->estoque < $item->quantidade()) {
                throw new \DomainException(
                    "Estoque insuficiente para \"{$produto->nome}\": "
                    . "disponível {$produto->estoque}, solicitado {$item->quantidade()}."
                );
            }
        }

        return DB::transaction(function () use ($carrinho, $usuario, $endereco, $observacoes) {
            $subtotal = $carrinho->total();
            $frete    = $subtotal->centavos() >= self::FRETE_GRATIS_ACIMA
                ? new Dinheiro(0)
                : new Dinheiro(self::FRETE_PADRAO);
            $total    = $subtotal->somar($frete);

            // Cria o pedido
            $pedido = Pedido::create([
                'usuario_id'  => $usuario->id,
                'endereco_id' => $endereco->id,
                'status'      => StatusDoPedido::Pendente,
                'subtotal'    => $subtotal->reais(),
                'frete'       => $frete->reais(),
                'desconto'    => 0,
                'total'       => $total->reais(),
                'observacoes' => $observacoes,
            ]);

            // Cria os itens e decrementa o estoque
            foreach ($carrinho->itens() as $item) {
                $produto = Produto::find($item->produtoId);

                PedidoItem::create([
                    'pedido_id'      => $pedido->id,
                    'produto_id'     => $item->produtoId,
                    'nome_produto'   => $item->nomeProduto,      // snapshot
                    'preco_unitario' => $item->precoUnitario->reais(),
                    'quantidade'     => $item->quantidade(),
                    'subtotal'       => $item->subtotal()->reais(),
                ]);

                // Decrementa o estoque de forma atômica
                $produto->decrement('estoque', $item->quantidade());
            }

            // Dispara o job de processamento assíncrono
            ProcessarPedido::dispatch($pedido->id);

            return $pedido;
        });
    }

    public function cancelar(Pedido $pedido, User $usuario): void
    {
        if ($pedido->usuario_id !== $usuario->id && !$usuario->isAdmin()) {
            throw new \DomainException('Sem permissão para cancelar este pedido.');
        }

        DB::transaction(function () use ($pedido) {
            $pedido->transicionarPara(StatusDoPedido::Cancelado);

            // Devolve o estoque
            foreach ($pedido->itens as $item) {
                Produto::where('id', $item->produto_id)
                    ->increment('estoque', $item->quantidade);
            }
        });
    }
}

Controllers Web

<?php
declare(strict_types=1);

namespace App\Http\Controllers\Web;

use App\Http\Controllers\Controller;
use App\Models\Produto;
use App\Models\Categoria;
use Illuminate\Http\Request;
use Illuminate\View\View;

class CatalogoController extends Controller
{
    public function index(Request $request): View
    {
        $query = Produto::disponivel()->with('categoria');

        if ($request->filled('categoria')) {
            $query->whereHas('categoria', fn($q) =>
                $q->where('slug', $request->categoria)
            );
        }

        if ($request->filled('busca')) {
            $termo = $request->busca;
            $query->where(fn($q) =>
                $q->where('nome', 'ilike', "%{$termo}%")
                  ->orWhere('descricao', 'ilike', "%{$termo}%")
            );
        }

        $query->orderBy(match($request->get('ordenar', 'relevancia')) {
            'preco_asc'  => 'preco',
            'preco_desc' => 'preco',
            'nome'       => 'nome',
            default      => 'criado_em',
        }, $request->get('ordenar') === 'preco_asc' ? 'asc' : 'desc');

        return view('catalogo.index', [
            'produtos'   => $query->paginate(16)->withQueryString(),
            'categorias' => Categoria::where('ativa', true)->orderBy('nome')->get(),
        ]);
    }

    public function show(string $slug): View
    {
        $produto    = Produto::where('slug', $slug)->disponivel()->firstOrFail();
        $relacionados = Produto::disponivel()
            ->daCategoria($produto->categoria_id)
            ->where('id', '!=', $produto->id)
            ->limit(4)
            ->get();

        return view('catalogo.show', compact('produto', 'relacionados'));
    }
}

class CarrinhoController extends Controller
{
    public function __construct(private readonly CarrinhoService $carrinhoService) {}

    public function adicionar(Request $request): \Illuminate\Http\RedirectResponse
    {
        $request->validate([
            'produto_id' => ['required', 'integer', 'exists:produtos,id'],
            'quantidade' => ['required', 'integer', 'min:1', 'max:99'],
        ]);

        $produto = Produto::findOrFail($request->produto_id);

        if (!$produto->estaDisponivel()) {
            return back()->with('erro', 'Produto indisponível.');
        }

        $this->carrinhoService->adicionar(
            $produto,
            (int) $request->quantidade,
        );

        return back()->with('sucesso', "\"{$produto->nome}\" adicionado ao carrinho.");
    }

    public function index(): View
    {
        $carrinho = $this->carrinhoService->carrinho();
        return view('carrinho.index', compact('carrinho'));
    }

    public function atualizar(Request $request, int $produtoId): \Illuminate\Http\RedirectResponse
    {
        $request->validate(['quantidade' => ['required', 'integer', 'min:0', 'max:99']]);
        $this->carrinhoService->atualizar($produtoId, (int) $request->quantidade);
        return back()->with('sucesso', 'Carrinho atualizado.');
    }

    public function remover(int $produtoId): \Illuminate\Http\RedirectResponse
    {
        $this->carrinhoService->remover($produtoId);
        return back()->with('sucesso', 'Item removido do carrinho.');
    }
}

Job de processamento de pedido

<?php
declare(strict_types=1);

namespace App\Jobs;

use App\Models\Pedido;
use App\Domain\Pedido\StatusDoPedido;
use App\Mail\PedidoConfirmado;
use App\Mail\PedidoParaAdmin;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Mail;

class ProcessarPedido implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable;

    public int $tries   = 3;
    public int $timeout = 60;

    public function __construct(private readonly int $pedidoId) {}

    public function handle(): void
    {
        $pedido = Pedido::with(['usuario', 'itens', 'endereco'])->findOrFail($this->pedidoId);

        // Simula processamento de pagamento (em produção: gateway real)
        $pagamentoAprovado = $this->simularPagamento($pedido);

        if ($pagamentoAprovado) {
            $pedido->transicionarPara(StatusDoPedido::Confirmado);

            // Envia email para o cliente
            Mail::to($pedido->usuario->email)
                ->send(new PedidoConfirmado($pedido));

            // Notifica o admin
            Mail::to(config('mercatto.email_admin'))
                ->send(new PedidoParaAdmin($pedido));

        } else {
            $pedido->transicionarPara(StatusDoPedido::Cancelado);

            // Devolve o estoque
            foreach ($pedido->itens as $item) {
                \App\Models\Produto::where('id', $item->produto_id)
                    ->increment('estoque', $item->quantidade);
            }
        }
    }

    private function simularPagamento(Pedido $pedido): bool
    {
        // Em produção: integração com Stripe, PagSeguro, Mercado Pago etc.
        return true;
    }

    public function backoff(): array { return [30, 60, 120]; }

    public function failed(\Throwable $e): void
    {
        \Log::error("Falha ao processar pedido {$this->pedidoId}", [
            'erro' => $e->getMessage(),
        ]);
    }
}

Rotas completas

<?php
// routes/web.php
use App\Http\Controllers\Web\{CatalogoController, CarrinhoController, CheckoutController, PedidoController};
use App\Http\Controllers\Auth\{LoginController, RegisterController};

// Públicas
Route::get('/', [CatalogoController::class, 'index'])->name('home');
Route::get('/produtos/{slug}', [CatalogoController::class, 'show'])->name('produtos.show');

// Autenticação
Route::middleware('guest')->group(function () {
    Route::get('/login',    [LoginController::class, 'create'])->name('login');
    Route::post('/login',   [LoginController::class, 'store']);
    Route::get('/register', [RegisterController::class, 'create'])->name('register');
    Route::post('/register',[RegisterController::class, 'store']);
});
Route::post('/logout', [LoginController::class, 'destroy'])->name('logout')->middleware('auth');

// Carrinho — acesso público (guest também pode adicionar)
Route::prefix('carrinho')->name('carrinho.')->group(function () {
    Route::get('/',                           [CarrinhoController::class, 'index'])->name('index');
    Route::post('/adicionar',                 [CarrinhoController::class, 'adicionar'])->name('adicionar');
    Route::patch('/atualizar/{produtoId}',    [CarrinhoController::class, 'atualizar'])->name('atualizar');
    Route::delete('/remover/{produtoId}',     [CarrinhoController::class, 'remover'])->name('remover');
});

// Área autenticada
Route::middleware('auth')->group(function () {
    Route::get('/checkout',  [CheckoutController::class, 'index'])->name('checkout');
    Route::post('/checkout', [CheckoutController::class, 'store'])->name('checkout.store');
    Route::resource('pedidos', PedidoController::class)->only(['index', 'show']);
    Route::post('/pedidos/{pedido}/cancelar', [PedidoController::class, 'cancelar'])->name('pedidos.cancelar');
});
<?php
// routes/admin.php
Route::prefix('admin')->name('admin.')->middleware(['auth', 'role:admin'])->group(function () {
    Route::get('/', fn() => view('admin.dashboard'))->name('dashboard');
    Route::resource('produtos', Admin\ProdutoController::class);
    Route::resource('pedidos',  Admin\PedidoController::class)->only(['index', 'show', 'update']);
    Route::resource('categorias', Admin\CategoriaController::class);
    Route::get('/relatorios/vendas', [Admin\RelatorioController::class, 'vendas'])->name('relatorios.vendas');
});

Exercício da semana

  1. Implemente PedidoService::criarDesdoCarrinho() completo com todos os casos de erro — carrinho vazio, produto indisponível, estoque insuficiente. Escreva testes de feature que cobrem cada caso.

  2. Implemente os controllers de catálogo e carrinho. Crie as views Blade correspondentes com layout, listagem de produtos, página de detalhe, e página do carrinho com atualização de quantidade.

  3. Implemente o checkout: formulário com seleção de endereço existente ou novo, confirmação do pedido, e redirecionamento para a página do pedido criado com flash message.

  4. Implemente o painel admin de pedidos: listagem com filtro por status, página de detalhe com timeline de status, e botão para avançar o status com validação de transição.

  5. Desafio: implemente um relatório de vendas em /admin/relatorios/vendas com filtro de período. Use uma query com GROUP BY dia, soma de totais e contagem de pedidos. Exiba em tabela e, usando Chart.js via CDN no Blade, em um gráfico de linha.


Referências

  • Laravel Queues (Jobs): https://laravel.com/docs/queues
  • Laravel Mail: https://laravel.com/docs/mail
  • Laravel Transactions: https://laravel.com/docs/database#database-transactions
  • Eloquent Scopes: https://laravel.com/docs/eloquent#query-scopes

Comentários

Mais em PHP

SQLite com PHP
SQLite com PHP

SQLite é o banco de dados mais instalado do mundo — está presente em todos os...

PHP 8.x Moderno
PHP 8.x Moderno

O PHP 8.x trouxe as mudanças mais significativas da linguagem em uma década....

Dominando o PHP
Dominando o PHP

Se voc&ecirc; chegou at&eacute; aqui, provavelmente j&aacute; ouviu algu&eacu...