Validar CPF em PHP e Laravel: regra customizada, dígitos verificadores e consulta via API
Transparência: sou fundador da CPFHub, uma das APIs citadas no final. O código de validação é independente de fornecedor.
Quase todo sistema brasileiro em PHP tem um campo de CPF. E quase todos começam com uma regex ^\d{11}$ que aceita 11111111111. Neste artigo vou montar, em PHP puro e depois em Laravel, as três camadas que realmente resolvem o problema:
- Normalização (tirar máscara e espaços);
- Validação matemática (dígitos verificadores);
- Confirmação externa (opcional), quando você precisa saber se o CPF existe e bate com o nome.
1. Normalização
O usuário vai digitar 123.456.789-09, 123456789 09 ou colar com espaço no final. Normalize antes de qualquer coisa:
function normalizarCpf(string $cpf): string {
return preg_replace('/\D/', '', $cpf);
}
Guarde sempre a versão só com dígitos no banco. A máscara é responsabilidade da camada de apresentação.
2. Dígitos verificadores
O CPF tem 9 dígitos-base e 2 verificadores. O primeiro DV usa pesos de 10 a 2 sobre os 9 primeiros dígitos; o segundo usa pesos de 11 a 2 sobre os 10 primeiros. Em ambos, resto = (soma * 10) % 11, e se o resto for 10, vira 0.
function cpfValido(string $cpf): bool {
$cpf = normalizarCpf($cpf);
if (strlen($cpf) !== 11) return false;
if (preg_match('/^(\d)\1{10}$/', $cpf)) return false; // 000..., 111...
for ($t = 9; $t < 11; $t++) {
$soma = 0;
for ($i = 0; $i < $t; $i++) {
$soma += (int)$cpf[$i] * (($t + 1) - $i);
}
$dv = ($soma * 10) % 11 % 10;
if ((int)$cpf[$t] !== $dv) return false;
}
return true;
}
var_dump(cpfValido('529.982.247-25')); // true
var_dump(cpfValido('111.111.111-11')); // false
var_dump(cpfValido('529.982.247-24')); // false
O % 11 % 10 é um atalho para a regra "se der 10, vira 0".
Pontos que costumam dar bug:
- Não converta para inteiro.
(int)'01234567890'perde o zero à esquerda. CPF é string. - Rejeite as sequências repetidas. Elas passam no cálculo, mas não são CPFs reais.
- Não use
is_numeric: ele aceita1e10.
3. Regra customizada no Laravel
No Laravel 10+ a forma mais limpa é uma Rule invocável:
php artisan make:rule Cpf
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class Cpf implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (!is_string($value) || !cpfValido($value)) {
$fail('O :attribute informado não é um CPF válido.');
}
}
}
E no Form Request:
public function rules(): array
{
return [
'nome' => ['required', 'string', 'max:120'],
'cpf' => ['required', new \App\Rules\Cpf, 'unique:clientes,cpf'],
];
}
protected function prepareForValidation(): void
{
$this->merge(['cpf' => preg_replace('/\D/', '', (string) $this->cpf)]);
}
O prepareForValidation garante que a regra unique compare a versão normalizada, evitando duplicidade entre 529.982.247-25 e 52998224725.
4. Quando a validação matemática não basta
O dígito verificador só prova que o número é bem formado. Ele não diz se o CPF existe nem se pertence à pessoa que está se cadastrando. Para isso, você precisa de uma fonte externa. Os casos típicos:
- cadastro em fintech, marketplace ou plataforma com pagamento;
- pré-preenchimento de nome e data de nascimento para reduzir digitação;
- conferir se o nome digitado bate com o titular do CPF.
A regra prática: valide localmente sempre (é grátis e instantâneo) e consulte a API só quando o CPF passar no DV. Isso evita gastar requisição com erro de digitação.
5. Consulta via API com o HTTP Client do Laravel
O exemplo abaixo usa a CPFHub, mas a estrutura (timeout, tratamento de 404 e 429) serve para qualquer provedor.
use Illuminate\Support\Facades\Http;
function consultarCpf(string $cpf): ?array
{
$cpf = normalizarCpf($cpf);
if (!cpfValido($cpf)) {
throw new InvalidArgumentException('CPF inválido');
}
$res = Http::withHeaders(['x-api-key' => config('services.cpfhub.key')])
->timeout(10)
->get("https://api.cpfhub.io/cpf/{$cpf}");
if ($res->status() === 404) return null; // não encontrado
if ($res->status() === 429) {
throw new RuntimeException('Limite por minuto; tente em '
. $res->header('Retry-After') . ' s');
}
$res->throw(); // 401, 403, 5xx
return $res->json('data'); // name, gender, birthDate...
}
Guarde a chave em config/services.php lendo de .env, nunca no código.
6. Comparando o nome
Nome digitado raramente bate caractere a caractere. Normalize acentos e caixa antes de comparar:
function normalizarNome(string $n): string {
$n = iconv('UTF-8', 'ASCII//TRANSLIT', $n);
return preg_replace('/\s+/', ' ', strtoupper(trim($n)));
}
Para tolerar abreviações ("Maria S. Souza"), compare primeiro e último nome, ou use similar_text com um limiar, e mande os casos duvidosos para revisão manual em vez de bloquear.
Sobre a CPFHub
Na CPFHub, a consulta simples é GET /cpf/{cpf} com header x-api-key, retorna nome, gênero e data de nascimento, custa 1 crédito por CPF encontrado (404 não consome) e responde em ~150 ms (tempo típico, não SLA). O plano gratuito tem 50 créditos por mês. Os detalhes estão na documentação.
Resumo
- Normalize e guarde só dígitos.
- Valide DV e rejeite sequências repetidas.
- Use
prepareForValidation+ Rule customizada no Laravel. - Consulte API externa só depois do DV, com timeout e tratamento de 404/429.