-2

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:

  1. Normalização (tirar máscara e espaços);
  2. Validação matemática (dígitos verificadores);
  3. 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 aceita 1e10.

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.
Carregando publicação patrocinada...