Concluído Pessoal PHP 8.4 Laravel 13 PostgreSQL 17 Docker Nginx AdminLTE v4 Bootstrap 5 Vite JavaScript PHPUnit

Desafio Vercan

Cadastro de fornecedores em Laravel 13 com arquitetura em camadas, CNPJ alfanumérico, exceções padronizadas e segurança aplicada

Desafio Vercan

O Desafio Vercan é um sistema de cadastro e gestão de fornecedores, pessoa física e jurídica, feito em Laravel 13 com PostgreSQL e tudo rodando em Docker. Comecei em 4 de outubro de 2026 e terminei no dia seguinte. No papel é um CRUD. Na prática virou um exercício de arquitetura em camadas, validação com regras próprias, tratamento de erro padronizado e segurança aplicada, com testes que protegem comportamento real em vez de só deixar a suíte verde.

Tecnologias

PHP 8.4 Laravel 13 PostgreSQL 17 Docker Compose Nginx Bootstrap 5 AdminLTE v4 Vite 8 JavaScript PHPUnit

No front, Blade renderizado no servidor com AdminLTE, mais Tabulator (grid), SweetAlert2, IMask e Quill. Cada página que usa uma dessas bibliotecas tem seu próprio entry point no Vite, então as outras páginas não carregam o que não usam. Nada vem de CDN.

O que o sistema faz

Cadastra fornecedores com endereço, contatos, telefones e e-mails. O CEP preenche o endereço pelo ViaCEP e o CNPJ preenche os dados cadastrais pela ReceitaWS. Estados e cidades são carregados uma vez pela Brasil API no seeder.

A listagem usa uma grid com busca, ordenação e paginação feitas no servidor, e exporta para PDF e Excel respeitando exatamente o filtro que está na tela.

Arquitetura em camadas

O fluxo é sempre Controller, FormRequest, Service e Repository, sem atalho nem para o que parece trivial.

  • Controller recebe a requisição, chama o Service e devolve a resposta. Não tem regra de negócio nem query.
  • FormRequest faz a validação, uma classe por ação.
  • Service concentra a regra de negócio e a orquestração.
  • Repository é o único lugar onde o Eloquent aparece.

A injeção de dependência é por classe concreta. Não criei interface para cada repository "por garantia": interface só entra quando existe mais de uma implementação de verdade. As integrações externas ficam isoladas em app/Services/Integracoes.

Outra convenção: português no domínio (fornecedores, razao_social, cnpj_cpf) e inglês no esqueleto do framework (index, store, handle).

Banco de dados

O modelo tem sete tabelas de domínio. Contato principal e contatos adicionais ficam na mesma tabela, separados por uma coluna principal, para não duplicar o mesmo conceito em dois schemas.

Diagrama entidade-relacionamento do Desafio Vercan: estados, cidades, fornecedores, contatos, telefones e e-mails

  • Enums nativos: tipo de pessoa, recolhimento e tipos de telefone e e-mail são colunas enum, que o PostgreSQL materializa como CHECK constraint. O valor permitido é garantido pelo banco, não só pela aplicação.
  • Índice em toda FK: diferente do MySQL, o PostgreSQL não cria índice sozinho ao declarar uma chave estrangeira. Sem o índice explícito, joins e deletes em cascata fariam varredura completa da tabela.
  • Delete explícito: contatos, telefones e e-mails apagam em cascata; estado e cidade com fornecedor vinculado não podem ser apagados (RESTRICT).
  • cnpj_cpf como texto único de 14 caracteres, porque o CNPJ deixou de ser só número.

CNPJ alfanumérico com custom rules

A Receita Federal passou a emitir em 2026 o CNPJ alfanumérico: os 12 primeiros caracteres podem ser dígitos ou letras, e só os dois dígitos verificadores continuam numéricos. A validação está em App\Rules\CnpjValido, uma custom rule do Laravel (classe que implementa ValidationRule).

  • O formato é checado pela regex ^[0-9A-Z]{12}\d{2}$.
  • O módulo 11 usa ord(caractere) - 48 em vez do dígito literal, como define a nota técnica. Para um CNPJ só numérico a conta dá o mesmo resultado de sempre, então um algoritmo atende os dois formatos.
  • Há uma guarda contra caracteres todos iguais: 00000000000000 fecha o dígito verificador por acidente e passaria sem ela.

O CPF tem a sua própria rule, CpfValido, em vez de uma regra única ramificando pelo tamanho. Cada documento fica legível isoladamente. Os testes das duas usam documentos reais e os vetores oficiais da nota técnica, nunca um dígito calculado pela própria classe testada.

Tratamento uniformizado de exceções

ViaCEP, ReceitaWS e Brasil API lançam a mesma exceção própria, ServicoExternoIndisponivelException, quando a API de terceiros cai. Ela é tratada uma única vez, em bootstrap/app.php, e vira HTTP 503 com mensagem padronizada. Não existe try/catch repetido nos controllers, e qualquer integração nova já ganha o comportamento correto.

A distinção entre os casos é deliberada. CEP ou CNPJ inexistente responde 404. Serviço fora do ar responde 503. O front mostra "não encontrado" no primeiro caso e "tente novamente" no segundo, e nenhuma falha de rede trava o restante do formulário.

A mesma ideia vale para documento duplicado: se dois cadastros com o mesmo CNPJ chegam ao mesmo tempo, o Service captura a violação de unicidade do banco e devolve um erro de validação comum, em vez de um 500.

Segurança no login e nas rotas

O login usa o RateLimiter do Laravel manualmente, e não o middleware throttle da rota. A diferença é que o throttle conta toda requisição, inclusive login correto. Aqui só tentativas que falham contam (cinco por minuto), e um login certo zera o contador.

A chave do limite é e-mail em minúsculas mais IP. Isso trava ataques contra uma conta sem bloquear um IP inteiro compartilhado por NAT, e impede contornar o limite só trocando maiúsculas no e-mail. No logout, a sessão é invalidada e o token CSRF regenerado.

As rotas ficam em dois grupos, guest para o login e auth para todo o resto, incluindo as consultas de CEP e CNPJ, que passam pelo backend e nunca saem direto do navegador. Toda resposta leva headers de segurança como X-Frame-Options, X-Content-Type-Options e Referrer-Policy.

Outras defesas

  • SQL injection na ordenação: a grid só ordena por colunas de uma allowlist. Um campo como id); drop table fornecedores;-- é ignorado.
  • XSS armazenado: o HTML do editor de observações é sanitizado no backend com mews/purifier, aceitando só as tags que a barra do Quill produz. No JS, dado vindo do servidor entra por textContent ou por um helper de escape, nunca direto em innerHTML.
  • Formula injection no Excel: texto que começa com =, +, - ou @ é gravado como string. Sem isso, uma razão social como =HYPERLINK(...) viraria fórmula na planilha de quem exportou.
  • Campo que o usuário não escreve: a situação do CNPJ só é gravada se veio de uma consulta real à ReceitaWS na mesma sessão, para aquele CNPJ. Um POST direto com outro valor é ignorado.
  • Filtro de status: a busca reconhece "Ativo" e "Inativo" por prefixo, porque "Inativo" contém "ativo" e uma busca por substring misturaria os dois.

Testes

São 46 testes em PHPUnit, rodando em SQLite em memória, sem depender do container do banco. Cobrem login e rate limiting, CRUD com contatos aninhados, documento duplicado em concorrência, sanitização de XSS, a regra da situação do CNPJ, a allowlist da grid, as exportações e os status 200, 404 e 503 de cada integração.

O critério foi testar só o que pegaria um bug real de alto impacto, com valor esperado escrito como literal da regra de negócio e asserção específica o bastante para quebrar quando o comportamento quebrar.

O código e a documentação completa estão no repositório no GitHub.

Referências

  • BOLSANELLO, Paulo Roberto. Desafio-Vercan. GitHub, 2026. Disponível em: https://github.com/paulodm145/Desafio-Vercan. Acesso em: 5 out. 2026.
  • OWASP FOUNDATION. CSV Injection. OWASP, 2026. Disponível em: https://owasp.org/www-community/attacks/CSV_Injection. Acesso em: 5 out. 2026.
  • LARAVEL. Rate Limiting. Laravel Documentation, 2026. Disponível em: https://laravel.com/docs/rate-limiting. Acesso em: 5 out. 2026.

Screenshots