~ / tutoriais /sdd-em-laravel-pdf-vendas-specification-executavel $ _

SDD do zero em Laravel: transformando uma feature real em specification executável

Lucas Souza Lucas Souza 8 min de leitura Tutoriais
SDD do zero em Laravel: transformando uma feature real em specification executável

SDD do zero em Laravel: transformando uma feature real em specification executável

Você abre o Claude Code, descreve a feature em três linhas, manda rodar.

Ele entrega 400 linhas. Compila. Os testes (que ele mesmo escreveu) passam. Você dá merge.

Duas semanas depois, suporte abre ticket: o relatório de vendas em PDF está somando os pedidos cancelados, o nome do arquivo não tem a data, e quem é vendedor consegue ver o faturamento de outro vendedor. O agente não inventou — você simplesmente nunca disse pra ele que essas regras existiam.

Essa cena tem nome agora. É o que o pessoal do GitHub chamou de vibe coding quando lançou o Spec Kit em 2025: pedir feature pra IA achando que ela vai adivinhar contexto que está só na sua cabeça. Spec-Driven Development (SDD) é a resposta. A spec deixa de ser comentário em PR e vira input executável: a IA gera código a partir dela, não a partir do seu prompt curto.

Neste post a gente faz o ciclo completo em Laravel, com uma feature que parece trivial de propósito — exportar relatório de vendas em PDF — pra mostrar que SDD aparece exatamente nessas que parecem bobas. Vai ter PRD, spec.md, plan.md, tasks.md, código gerado, teste em Pest, e o ponto onde o humano ainda manda. Stack PHP, que é o nicho que ninguém cobre nesse papo.

TL;DR

O contexto: por que SDD virou pauta agora

Em 2025 o GitHub publicou o Spec Kit, a AWS lançou o Kiro, a Tessl empurrou a fronteira pra "spec-as-source", e o Thoughtworks publicou o ensaio mais lúcido sobre o assunto. SDD virou padrão emergente.

A tese, do próprio spec-driven.md do GitHub, é direta:

"Specifications don't serve code — code serves specifications. The PRD isn't a guide for implementation; it's the source that generates implementation."

Tradução prática: o documento de requisito deixa de ser PDF morto na pasta /docs e vira input que o agente lê. O ciclo oficial do Spec Kit é Constitution → Specify → Plan → Tasks → Implement, todos comandos /speckit.* no Claude Code, gerando arquivos versionáveis em .specify/specs/[feature]/.

Mas SDD não é silver bullet. A própria Birgitta Böckeler, da Thoughtworks, aponta duas dores que dificilmente vão sumir:

"I'd rather review code than all these markdown files."

E:

"The agent ultimately not follow all the instructions."

Resumo honesto: SDD reduz drasticamente a chance do agente alucinar regra de negócio, mas não elimina o trabalho de revisar nem a não-determinismo do LLM. A gente paga essa dor com markdown. Em troca, ganha rastreabilidade que prompt curto nunca dá.

E aqui é onde o ecossistema PHP fica de fora da conversa. Os exemplos públicos de SDD são quase todos em Python, Next.js ou TypeScript. Laravel raramente aparece. Vamos consertar isso.

Pré-requisitos

  • PHP 8.3, Composer, Laravel 11.
  • Claude Code instalado (npm install -g @anthropic-ai/claude-code).
  • Spec Kit via uv tool install specify-cli (ou rodar comando-a-comando manualmente, como vou mostrar).
  • Pest 3 (composer require pestphp/pest --dev).
  • barryvdh/laravel-dompdf pra geração do PDF.

Toda a estrutura que vai aparecer abaixo é clonável: copia os arquivos dentro de .specify/specs/sales-report-pdf/ num projeto Laravel novo, abre o Claude Code, e o ciclo roda.

A feature trivial que tem mais arestas do que parece

GET /reports/sales/export.pdf. Parece besta. Não é.

O que está implícito num pedido desses, e raramente vem no prompt:

  • Filtro de período. Default? "Últimos 30 dias" ou "mês corrente"? Em qual timezone?
  • Permissão. Admin enxerga tudo. Vendedor enxerga só os próprios pedidos. Como amarra com o User?
  • Status do pedido. Cancelado entra no relatório? Devolvido? Em rascunho?
  • Dados sensíveis. CPF do cliente vai mascarado ou cru? E se o time jurídico já bateu sobre LGPD?
  • Renderização. Mais de 5.000 linhas — síncrono trava o request. Vai pra fila? Email com link?
  • Nome do arquivo. relatorio.pdf quebra UX. vendas-2026-04-01-a-2026-04-30.pdf é o que o usuário espera.
  • Cache. O mesmo período pedido duas vezes em dois minutos gera o PDF de novo do zero?

Vibe coding nessa feature normalmente esquece duas ou três dessas. SDD obriga a tropeçar nelas no documento, antes do código.

Mão na massa: o ciclo SDD aplicado

Passo 1: o PRD humano (o que ainda precisa de gente)

Antes do agente entrar, alguém escreve o PRD. Foco em resultado e contexto de negócio, não em técnica. O Spec Kit chama isso de "intent".

# PRD: Exportação de relatório de vendas em PDF

## Problema
Time comercial precisa enviar relatório mensal de vendas para
contabilidade por email. Hoje exportam CSV e formatam manualmente
no Excel. Toma de 30 a 60 minutos por mês, por gerente.

## Outcome desejado
Botão "Exportar PDF" no painel de relatórios devolve o documento
no padrão visual da empresa, pronto pra enviar.

## Restrições conhecidas
- Apenas admin e gerente podem exportar.
- Vendedor só vê os próprios números, não exporta.
- LGPD: CPF do cliente sai mascarado (xxx.xxx.xxx-12).
- Pedidos cancelados entram com badge "cancelado", não somam no total.
- Período default: mês corrente, em timezone America/Sao_Paulo.

## Não-objetivos
- Não é gráfico interativo. É PDF estático.
- Não é exportação programada. É sob demanda.

Esse arquivo é o input cru. Ele não tem decisão técnica — nada de "use dompdf", nada de "endpoint REST". Isso é trabalho do /plan.

Passo 2: /specify — virar specification executável

No Claude Code, dentro do projeto Laravel:

/speckit.specify

Você cola o PRD. O agente devolve um spec.md estruturado em .specify/specs/sales-report-pdf/spec.md. A diferença é que ele explicita ambiguidades que o PRD escondeu:

# Spec: Exportação de relatório de vendas em PDF

## User Stories
US-1: Como gerente, quero exportar PDF do mês corrente em 1 clique.
US-2: Como admin, quero filtrar por intervalo arbitrário antes de exportar.
US-3: Como vendedor, quero exportar apenas os meus pedidos.

## Acceptance Criteria
AC-1: PDF gerado contém: cabeçalho com logo, período, total bruto,
      total líquido (sem cancelados), tabela de pedidos.
AC-2: Pedidos cancelados aparecem na tabela com badge visual,
      mas não compõem o total líquido.
AC-3: CPF do cliente renderizado no formato xxx.xxx.xxx-NN.
AC-4: Nome do arquivo: "vendas-{YYYY-MM-DD}-a-{YYYY-MM-DD}.pdf".
AC-5: Vendedor que tentar exportar período fora do próprio recebe 403.

## [NEEDS CLARIFICATION]
- NC-1: Acima de quantas linhas a geração vai pra fila?
- NC-2: PDF é cacheado? Por quanto tempo?
- NC-3: Email automático com link após geração assíncrona, ou só download?

Esses [NEEDS CLARIFICATION] são o ouro do SDD. O agente sinaliza o que ele não consegue inferir sozinho. Você responde, atualiza o spec, e segue. Sem isso, ele inventa default.

Passo 3: /plan — escolher stack e contratos

Agora sim entra a técnica. Comando:

/speckit.plan

Saída em .specify/specs/sales-report-pdf/plan.md:

# Plan: Exportação de relatório de vendas em PDF

## Stack
- Renderer: barryvdh/laravel-dompdf (suficiente pro layout simples).
- Action class: App\Actions\Reports\ExportSalesPdf.
- Controller: thin, só valida e chama a action.
- View: resources/views/reports/sales-pdf.blade.php.
- Fila: Laravel Queue. Threshold = 1.000 linhas.

## Contracts
- Endpoint: GET /reports/sales/export.pdf
- Query: ?from=YYYY-MM-DD&to=YYYY-MM-DD
- Resposta síncrona: application/pdf, 200.
- Resposta assíncrona: 202 + { job_id, poll_url }.

## Policies
- SalesReportPolicy::export — verifica role + scope de vendedor.

## Decisões registradas
- dompdf escolhido sobre Snappy: layout não usa CSS3 avançado.
- Cache: 5 minutos por (user_id + from + to).

Repare: o plan.md é o lugar onde decisão técnica vive versionada, não num PR comment que ninguém mais acha.

Passo 4: /tasks — quebrar em tarefas testáveis

/speckit.tasks

Saída em tasks.md:

# Tasks

T1. Criar migration de índice composto (user_id, status, created_at) em orders.
T2. Criar SalesReportPolicy com método export().
T3. Criar Action ExportSalesPdf com __invoke(User $user, CarbonPeriod $range).
T4. Criar SalesReportPdfRequest com validação de from/to.
T5. Criar SalesReportController@export, thin.
T6. Criar view Blade do PDF com mascaramento de CPF.
T7. Criar Job ExportSalesPdfJob para volumes acima do threshold.
T8. Pest: feature test para AC-1, AC-2, AC-3, AC-4, AC-5.
T9. Pest: unit test do mascaramento de CPF.
T10. Documentar no README a rota e os params.

Cada task é pequena, testável, e amarra a um AC. É aqui que o ganho aparece: em vez do agente cuspir 400 linhas de uma vez, ele entrega T1 → review → T2 → review.

Passo 5: /implement (com freio humano)

/speckit.implement T8

Antes de qualquer linha de aplicação, o agente escreve o teste Pest da T8. Pra valer a pena, peça que ele rode o teste antes de implementar — confirma que falha pelo motivo certo:

<?php

use App\Models\{User, Order};

it('exporta PDF com totais corretos ignorando cancelados (AC-2)', function () {
    $admin = User::factory()->admin()->create();

    Order::factory()->count(3)->for($admin)->create(['status' => 'paid', 'total' => 100]);
    Order::factory()->for($admin)->create(['status' => 'canceled', 'total' => 999]);

    $response = $this->actingAs($admin)
        ->get('/reports/sales/export.pdf?from=2026-04-01&to=2026-04-30');

    $response->assertOk();
    $response->assertHeader('content-type', 'application/pdf');

    $pdfText = (new \Smalot\PdfParser\Parser())
        ->parseContent($response->getContent())->getText();

    expect($pdfText)
        ->toContain('Total líquido: R$ 300,00')
        ->not->toContain('R$ 999,00');
});

Falhou? Bom. Agora o agente implementa a Action:

<?php

namespace App\Actions\Reports;

use App\Models\{Order, User};
use Barryvdh\DomPDF\Facade\Pdf;
use Carbon\CarbonPeriod;

final class ExportSalesPdf
{
    public function __invoke(User $user, CarbonPeriod $range): string
    {
        $orders = Order::query()
            ->whereBetween('created_at', [$range->start, $range->end])
            ->when($user->isVendedor(), fn ($q) => $q->where('user_id', $user->id))
            ->orderBy('created_at')
            ->get();

        $totalLiquido = $orders->reject->isCanceled()->sum('total');

        return Pdf::loadView('reports.sales-pdf', [
            'orders'       => $orders,
            'totalLiquido' => $totalLiquido,
            'range'        => $range,
        ])->output();
    }
}

Roda o Pest de novo: passa. Próxima task. Esse é o loop. Cada task entra no commit com referência ao AC e ao spec — rastreabilidade que PR descrição em texto livre nunca entrega.

Limitações e pontos de atenção (a parte que ninguém posta no thread)

1. Markdown sprawl. Quatro arquivos por feature pequena. Em projeto com 200 features, vira inferno. Solução: SDD não é pra todo CRUD. Aplica em features com regra de negócio — relatório, integração, fluxo de aprovação. Migration de coluna nova não merece spec.md.

2. O agente diverge mesmo com spec rígida. Já vi spec dizendo "máscara CPF xxx.xxx.xxx-NN" e o código sair com "..***-NN". Não é falha do método, é a não-determinismo do LLM. O Pest é a rede de proteção — sem teste, SDD vira só burocracia.

3. Constituição é frágil. O Spec Kit propõe um constitution.md com regras imutáveis ("toda Action é final", "controller é thin"). Funciona em projeto novo. Em legado de 5 anos, a constituição só vale onde você forçar. Não dá pra retroagir.

4. LGPD e dados sensíveis. A spec precisa dizer explicitamente o que sai mascarado. Se você esquecer, o agente expõe. Trate isso como acceptance criteria de primeira classe, não como detalhe de implementação.

5. Não é silver bullet. Feature com 1 query SQL e zero regra de negócio é overhead. SDD ganha onde o custo do erro é alto: relatório financeiro, integração com gateway, fluxo de pagamento, exportação que vai pra contabilidade.

FAQ

Funciona sem o Spec Kit oficial? Sim. O Spec Kit é só uma convenção de pastas + comandos /speckit.* no Claude Code. Você consegue o mesmo com .claude/commands/specify.md, plan.md e tasks.md custom. O claude-code-spec-workflow do Pimzino é uma alternativa pronta pra colar no projeto. O importante é o ciclo, não o nome do comando.

Preciso refazer o projeto inteiro? Não. SDD é por feature. Legado segue como está. Toda feature nova entra pelo ciclo, e em 6 meses metade do código que importa já está versionada com spec.

Os testes Pest substituem o spec? Não. Spec é "o quê" e "por quê". Teste é "como verifico que funciona". Camadas diferentes. Quando alguém pergunta por que o pedido cancelado não soma, o spec responde. Quando alguém quer saber se ainda funciona depois do refactor, o teste responde.

dompdf aguenta relatório grande? Pra PDF tabular simples, sim — até uns 2.000-3.000 pedidos render bem. Acima disso, ou se tiver gráfico/CSS3 avançado, troque por Browsershot (Headless Chrome). Mas isso é detalhe que mora no plan.md, não no spec.md.

Conclusão

SDD aplicado a Laravel não é mudança de framework, é mudança de input. O agente sai do modo adivinhador e entra no modo executor: a spec, com acceptance criteria explícitos, é o que ele lê pra gerar código. O markdown extra paga em rastreabilidade, em [NEEDS CLARIFICATION] que pega ambiguidade antes do PR, e em commits que amarram código a regra de negócio. O que sobra pro humano é o que sempre foi: pensar o problema, decidir trade-off, e revisar com critério.

O próximo passo depois de SDD é harness engineering — montar o ferramental ao redor do agente pra ele rodar em produto real, não em chatbot de demo. É exatamente o que vou destrinchar ao vivo no Harness Engineering com Claude Code, 16 e 17 de maio: dois dias construindo do zero um app que recebe link de produto, pesquisa alternativas em e-commerces, compara preço e devolve recomendação estruturada — Claude Code, Laravel, NativePHP, sem prompt mágico e sem chatbot.

Sources:

Lucas Souza
Lucas Souza

{AI Engineer} — apaixonado por Laravel, arquitetura de software e construir produtos com impacto. Compartilho aqui tutoriais, descobertas e reflexões sobre o dia a dia de engenharia.

Você também pode gostar

VirguIA

beer & code assistant

conectando…

Não foi possível iniciar o chat agora.

tocando