singra / br-validation
Validate, format, generate and redact Brazilian documents. Pure PHP, zero production dependencies.
Requires
- php: ^8.3
Requires (Dev)
- laravel/pint: ^1.30
- pestphp/pest: ^4.7
- pestphp/pest-plugin-type-coverage: ^4.0
- phpstan/phpstan: ^2.2
- rector/rector: ^2.6
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
truefor 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:
- External vectors, cross-checked against an independently authored implementation of the same specifications.
- Seeded round-trip fuzzing —
isValid(generate()), andgenerate(uf: X)->uf() === X. - One targeted invalid per
Reason. - 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.