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
- O que o sistema faz
- Arquitetura em camadas
- Banco de dados
- CNPJ alfanumérico com custom rules
- Tratamento uniformizado de exceções
- Segurança no login e nas rotas
- Outras defesas
- Testes
- Referências
Tecnologias
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.

- Enums nativos: tipo de pessoa, recolhimento e tipos de telefone e e-mail são colunas
enum, que o PostgreSQL materializa comoCHECKconstraint. 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_cpfcomo 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) - 48em 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:
00000000000000fecha 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 portextContentou por um helper de escape, nunca direto eminnerHTML. - 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.