singra/br-validation

Validate, format, generate and redact Brazilian documents. Pure PHP, zero production dependencies.

Maintainers

Package info

github.com/singraworks/br-validation

pkg:composer/singra/br-validation

Transparency log

Statistics

Installs: 16

Dependents: 1

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-07 12:11 UTC

This package is not auto-updated.

Last update: 2026-08-08 11:56:35 UTC


README

English · Português

Validate, format, generate and redact Brazilian documents.

Each document is a final readonly value object. Cpf::from() returns a Cpf, and holding one is proof the check digits were verified — once, at the boundary, rather than re-derived by every layer that touches it.

Pure PHP 8.3+. The require block is php and nothing else: no framework, no HTTP client, no network lookups.

composer require singra/br-validation

Quick start

use Singra\BrValidation\Documents\Cpf;

Cpf::isValid('111.444.777-35');   // true

$cpf = Cpf::from('111.444.777-35');

$cpf->value();                    // '11144477735'
$cpf->formatted();                // '111.444.777-35'
$cpf->redacted();                 // '***.444.777-**'
$cpf->fiscalRegion()->states();   // [Uf::ES, Uf::RJ]

Documents

Class Length Validation Extra accessors
Documents\Cpf 11 mod-11 ×2 fiscalRegion()
Documents\Cnpj 14 mod-11 ×2 over ord − 48 root() branch() isHeadquarters() isAlphanumeric()
Documents\Cep 8 no check digit — Correios allocation table uf()

API

Every document exposes the same surface.

// Construction
Cpf::from(string $value): Cpf                  // throws InvalidDocument
Cpf::tryFrom(string $value): ?Cpf              // null instead of throwing
Cpf::isValid(string $value): bool

// Display, without verifying the check digits
Cpf::mask(string $value): string
Cpf::redact(string $value, Redaction $strategy = Redaction::Default): string

// Instance
$cpf->value(): string                          // '11144477735'
$cpf->formatted(): string                      // '111.444.777-35'
$cpf->redacted(Redaction $strategy = Redaction::Default): string
(string) $cpf                                  // '11144477735'
json_encode($cpf)                              // '"11144477735"'

__toString() and jsonSerialize() both return the raw value. Display is always a deliberate call, so nothing bound to a CHAR(11) column receives punctuation by accident.

Constructors are private — from(), tryFrom() and generate() are the only ways to obtain an instance, and all three validate.

Cnpj

Numeric and alphanumeric CNPJs both go through one mod-11 routine. Since July 2026 (IN RFB nº 2.229/2024) the root and branch may contain A–Z; each position contributes its ASCII value minus 48, which leaves 0–9 at face value and reduces exactly to the pre-2026 arithmetic. Check digits stay numeric. Every CNPJ issued before the change remains valid.

$cnpj = Cnpj::from('11.222.333/0001-81');
$cnpj->root();             // '11222333'
$cnpj->branch();           // '0001'
$cnpj->isHeadquarters();   // true
$cnpj->isAlphanumeric();   // false

$cnpj = Cnpj::from('plbcfsrl000140');   // lowercase is normalised
$cnpj->value();            // 'PLBCFSRL000140'
$cnpj->formatted();        // 'PL.BCF.SRL/0001-40'
$cnpj->isAlphanumeric();   // true

If your storage cannot hold letters, check isAlphanumeric() at your boundary rather than reaching for a different validator.

Cep

CEP has no check digit — no arithmetic distinguishes a real one from an invented one. Validation is shape, plus membership of a block Correios actually allocated, plus rejection of uniform sequences, which fill Brazilian address tables as placeholders.

$cep = Cep::from('01310-100');
$cep->uf();            // Uf::SP
$cep->uf()->label();   // 'São Paulo'

Cep::isValid('00123-456');   // false — no block was ever allocated there
Cep::isValid('11111-111');   // false — uniform, though inside São Paulo's block
Cep::isValid('78950-000');   // false — vacated when Rondônia was renumbered

uf() is non-nullable: allocation is enforced at construction, so there is always an answer. The trade is that the bundled table is load-bearing — a block allocated in future would be rejected until the table catches up. This does not promise the address exists.

Cpf

$region = Cpf::from('111.444.777-35')->fiscalRegion();

$region;             // FiscalRegion::Seventh
$region->number();   // 7
$region->states();   // [Uf::ES, Uf::RJ]

There is deliberately no Cpf::uf(). The ninth digit identifies the Receita Federal region that issued the CPF, not where the holder lives, and eight of the ten regions cover more than one state.

Input handling

Whitespace — including internal — plus ., - and / are removed. Everything left must belong to the document's alphabet.

Cpf::isValid('11144477735');           // true
Cpf::isValid('111.444.777-35');        // true
Cpf::isValid('111 444 777 35');        // true
Cpf::isValid('  111.444.777-35  ');    // true

Cpf::isValid('CPF: 111.444.777-35');   // false — letters
Cpf::isValid('1a2b3c4d5e6');           // false — letters

Packages that strip every non-digit accept those last two. This one does not: a Cpf that can be built from arbitrary prose proves nothing about its input.

Leading zeros are never restored. A CPF read back out of an INT column arrives ten characters long and is rejected. Repairing it belongs at the boundary where the data was damaged, not here.

Cpf::tryFrom('1234567890');   // null — not silently read as '01234567890'

Errors

from() throws InvalidDocument, which extends InvalidArgumentException and implements the BrValidationException marker. Its message is English, for logs; reason is the machine-readable contract to build user-facing text from.

use Singra\BrValidation\Enums\Reason;
use Singra\BrValidation\Exceptions\InvalidDocument;

try {
    $cpf = Cpf::from($input);
} catch (InvalidDocument $e) {
    $e->document;   // 'cpf'
    $e->reason;     // Reason::WrongLength
    $e->getMessage();

    return match ($e->reason) {
        Reason::WrongLength        => 'CPF deve ter 11 dígitos',
        Reason::IllegalCharacter   => 'CPF deve conter apenas números',
        Reason::RepeatedCharacters => 'CPF inválido',
        Reason::InvalidCheckDigit  => 'CPF inválido',
        Reason::UnallocatedRange   => 'CEP não existe',
    };
}
Reason Raised when Documents
WrongLength wrong number of characters after normalisation all
IllegalCharacter a character outside the alphabet survived normalisation all
RepeatedCharacters every character is the same all
InvalidCheckDigit check digits do not match the rest Cpf Cnpj
UnallocatedRange well formed, but issued by nobody Cep

RepeatedCharacters is a separate rule, not a redundant one: every uniform sequence satisfies mod-11. 111.111.111-11 checks out perfectly.

Redaction

use Singra\BrValidation\Enums\Redaction;

$cpf->redacted(Redaction::Head);   // '111.***.***-**'
Strategy Cpf Cnpj Cep
Default ***.444.777-** **.222.333/0001-** 01310-***
Head 111.***.***-** 11.***.***/****-** 01310-***
Tail ***.***.777-** **.***.***/0001-** *****-100
Full ***.***.***-** **.***.***/****-** *****-***

Every strategy hides the check digits, and that is the point. Check digits are derived from the rest of the document. Publishing ***.456.789-09 leaves 1,000 candidates for the hidden group, and the two visible check digits narrow that to roughly 8. Brazilian government portals redact as ***.456.789-** for exactly this reason.

CEP hides the suffix instead, that being what narrows a code to a street or a single building. Head and Default coincide there, CEP having only two groups.

Generation

Uses PHP's core Random\Randomizer — the CSPRNG by default, seedable for reproducible fixtures.

use Random\Engine\Xoshiro256StarStar;
use Random\Randomizer;
use Singra\BrValidation\Enums\{FiscalRegion, Uf};

Cpf::generate(?Randomizer $randomizer = null, ?FiscalRegion $region = null): Cpf
Cnpj::generate(?Randomizer $randomizer = null, bool $alphanumeric = false): Cnpj
Cep::generate(?Randomizer $randomizer = null, ?Uf $uf = null): Cep
Cpf::generate();                               // CSPRNG

$seeded = new Randomizer(new Xoshiro256StarStar(hash('sha256', 'seed', true)));
Cpf::generate($seeded)->value();               // '01326741225', every run

Cpf::generate(region: FiscalRegion::Eighth);   // issued in São Paulo
Cep::generate(uf: Uf::PE);                     // inside a Pernambuco block
Cnpj::generate(alphanumeric: true);            // 'K9.VWW.FYR/0001-34'

Cnpj::generate() is numeric by default, since that is still what almost every system in the country holds. With alphanumeric: true at least one letter is guaranteed, so the flag always means what it says. Generated CNPJs are head office records (0001).

Cep::generate() draws uniformly across a state's real allocated space rather than picking a block first, which matters for states split across blocks of very different sizes.

Displaying data that does not validate

Production tables hold documents with bad check digits, entered years ago, that still have to render on a screen or an invoice. mask() and redact() are static, enforce shape, and deliberately do not consult the check digits.

Cpf::isValid('11144477700');   // false
Cpf::mask('11144477700');      // '111.444.777-00'
Cpf::redact('11144477700');    // '***.444.777-**'

Cpf::mask('1234');             // throws — shape is still enforced

The verbs carry the distinction: from() and isValid() verify; mask() and redact() only arrange.

Not included

  • RG. No national standard, 27 incompatible state formats, most uncheckable. A validator returning true for an RG would be lying.
  • Inscrição Estadual. 27 distinct algorithms, comparable in size to everything here combined.
  • Network lookups. No ViaCEP, no Receita, no PSR-18 dependency. Offline arithmetic and one small bundled table.
  • Framework bridges. A Laravel rule is three lines around isValid(); it does not need to live here.

Development

composer test           # Pest
composer test:coverage  # 100% line coverage, enforced
composer test:mutate    # mutation testing, ≥85%
composer analyse        # PHPStan, level max, src and tests
composer lint           # Rector then Pint, writing
composer lint:test      # both, dry run
composer check          # lint:test, analyse, test:coverage

Coverage needs Xdebug or PCOV. The suite is built in four layers, each catching what the others structurally cannot:

  1. External vectors, cross-checked against an independently authored implementation of the same specifications.
  2. Seeded round-trip fuzzingisValid(generate()), and generate(uf: X)->uf() === X.
  3. One targeted invalid per Reason.
  4. Mutation testing and architecture assertions.

Layer 1 is what makes the rest trustworthy. Fuzzing proves only that this package's generator and validator agree with each other, and they would agree perfectly while sharing a transposed weight vector.

New test vectors must be verified against an implementation other than this one. See CONTRIBUTING.md.

License

MIT. See LICENSE.