italix / rules
Validation vocabulary and executor: machine error codes rather than prose, and a third verdict — unknown — for the checks it cannot honestly settle
Requires
- php: >=7.4
- ext-mbstring: *
- italix/contracts: ^2.0
Requires (Dev)
- italix/i18n: ^2.0
- italix/testing: ^2.0
Suggests
- ext-intl: Not used by the checks; italix/i18n needs it to render the messages in a locale's own conventions
This package is not auto-updated.
Last update: 2026-08-31 06:13:03 UTC
README
Validation vocabulary and executor: immutable Rule descriptors, a Checker that runs them,
machine error codes rather than prose, and honest deferral for the checks it cannot settle.
$outcome = $checker->check_all([ 'email' => [Rule::required(), Rule::email()], 'tax_code' => [Rule::tax_code()], 'vat_number' => [Rule::required_with('company_name'), Rule::vat_number()], 'fund_iban' => [Rule::iban('IT')], 'lines.*.qty' => [Rule::integer(), Rule::min(1)], ], $data); $outcome->is_valid(); // nothing was contradicted $outcome->is_confirmed(); // …and everything was actually checked $outcome->errors(); // field => machine code $outcome->unverified(); // field => "nobody could answer this" $outcome->deferred(); // rules that need a database, listed rather than ignored
The idea the rest follows from: three verdicts, not two
Most validators answer valid or invalid. That forces a lie whenever the library has no way to decide, and the lie is always the same one — valid, because it is the polite direction.
Italix\Rules answers three things:
| meaning | |
|---|---|
| pass | checked, and it holds |
| fail | checked, and it does not — error_c says why |
| unknown | nothing contradicted it and nothing confirmed it |
A VAT number from a country whose algorithm this library does not carry comes back unknown. It
is not rejected — refusing a customer's genuine number is an argument nobody wins — and it is not
waved through either. is_valid() stays true so existing code keeps working; is_verified() is the
new question, and Outcome::unverified() names the fields nobody could answer for.
This is why the library reports error_c codes and not sentences. The wording is the application's
business, and its language is Italix\I18n's.
What it checks
Formats — email, phone, tax_code (Italian codice fiscale, including omocodia and the
municipality when a source is supplied), vat_number (the control digit of eighteen European
countries), iban, zip_code (57 countries), luhn, bic, barcode / isbn / issn,
uuid. And, behind an opt-in, national_id for 23 countries — see below.
Structure — presence, lengths, comparisons, in / not_in, pattern, dates, and the rules
about how fields relate: same, different, confirmed, required_if, required_with,
required_without_all, required_one_of, exactly_one_of, prohibited_if…
Paths and wildcards — a schedule key may be billing.city or lines.*.qty, and a failure is
reported on lines.1.qty: an error that does not say which row is useless in a form with ten.
Deferred — unique and exists need a data store, so they are listed in
Outcome::deferred() for the application to run. A rule that silently does nothing is worse than no
rule at all.
Configuration
new Checker( country_c: 'IT', // the default for rules that do not name one check_email_dns: false, // may e-mail checks touch the network? municipality_codes: $source, // reference data for tax codes, or null ); $rules->in('DE')->check_all($schedule, $party); // an immutable copy, another country $rules->register('my_customer_code', $check); // formats the library does not ship
in() matters as soon as one record holds parties of different nationalities: without it the
country has to be threaded through every call.
Regional identifiers, when you ask for them
use Italix\Rules\Checks\Regional\RegionalChecks; RegionalChecks::register($checker); $checker->in('NL')->national_id($value); // dispatched by country $checker->in('DE')->national_id($value, number_c: 'steuernummer'); $checker->check('bsn', $value); // Netherlands — the elfproef $checker->check('pesel', $value); // Poland — check digit and the encoded date $checker->check('hetu', $value); // Finland — the century is a letter, not a hyphen $checker->check('rrn', $value); // Belgium — no century field, so both forms are tried
23 countries, 24 numbers, reachable either by national_id or by the name the country uses. The
five member states not carried — Cyprus, Hungary, Luxembourg, Latvia, Malta — publish no check digit,
and the answer says exactly that (no_published_algorithm) rather than the weaker
country_not_carried, which means nobody has looked yet.
Where a country issues two numbers — Germany, France — which one is meant is the caller's to say,
and the answer reports back number_c and kind_c (both / register / tax). A stored identifier
that does not record which number it is becomes unusable the day somebody needs the other one.
Not registered by default. A personal identifier is not an IBAN: in several jurisdictions it carries a legal regime about who may store one and for how long, and registered with the rest they would be one autocomplete away from every form in every project. Behind that call, the person who types it is the person who decided they need it.
The interesting half of each is the part that is not arithmetic — a Belgian number carries no
century, so anyone born from 2000 needs a 2 prepended before the modulus, and a checker that tries
one form only rejects everybody born this century.
Messages
The library ships lang/{en,it}/rules.php, keyed {rule}.{error_c} with {rule}.invalid as the
fallback. Whoever defines the codes can define the sentences for them — otherwise every project
rewrites the same forty.
With Italix\Mvc\Translator, merge them beneath your own so your wording still wins:
new Translator( canonical_lang: 'en', supported_langs: ['en', 'it'], lang_dir: __DIR__ . '/lang/', groups: ['rules' => __DIR__ . '/../../Libs/Italix/Rules/lang'], );
The browser mirror
js/ holds the same checks in JavaScript: pure functions over strings, no DOM, no framework, no
dependency. It runs in a browser, in a Web Worker, in Node, and under React, Vue, Alpine or
nothing at all.
import { check } from '/js/italix/rules.js'; check('iban', value, { country_c: 'IT' }); check('required_with', value, { fields: ['company_name'] }, record);
js/rules.js the arithmetic — hand-written
js/rules-data.js the tables — GENERATED, do not edit
js/rules-alpine.js an optional Alpine binding, ~90 lines
Publishing it: the files are plain ES modules with no build step. Serve js/ from your document
root however you prefer — a copy in a deploy step, or a symlink:
ln -s ../../vendor/italix/rules/js public/js/italix
Three properties worth knowing before you trust it
The three verdicts cross over. check() returns {ok, verified, error_c}, so an unverifiable
value is neither green nor red. Render the third state as a third thing.
The browser knows less than the server, and says so. An Italian tax_code is never
verified in the browser: the birthplace is checked against a table that lives in the database. A
browser more confident than the server is worse than a browser that does not validate — it puts a
tick on a value the save will refuse and leaves the person nothing to read.
Some rules are declined, not approximated. date, date_format, url, mime, unique and
exists come back verified: false. PHP's strtotime parses "last day of next month"; no browser
reimplementation would agree with it on the odd inputs, which is exactly where a client-side
validator does its damage.
And email is one-sided. It reports only what it can be certain the server rejects — no @, a
space, a doubled dot — and answers unknown otherwise. Measured against a corpus of awkward
addresses, a plausible regex disagreed with PHP's validator in both directions; this one over-rejects
nothing and still catches two thirds of the real mistakes.
How it is kept honest
tests/MirrorTest.php generates a corpus — real values, near misses, every single-digit mutation of
four valid VAT numbers, rubbish — runs it through both PHP and Node, and compares 746 verdicts
one by one. It also asserts that the browser is never more confident than the server, and that
every code the corpus produces has a sentence in the shipped catalogue.
tests/GeneratedTest.php fails when js/rules-data.js has drifted from the PHP tables. Regenerate
with ix rules:emit-js; --check verifies without writing.
The suites skip with a message when Node is absent, rather than failing: a machine that cannot answer the question should not train anyone to ignore a red suite.
Provenance
See CREDITS.md. The European VAT check digits were verified against two independent
implementations under incompatible licences, which is how a real defect in the Maltese rule was
found; the postal-code patterns come from Google's libaddressinput.
Licensed under the Mozilla Public License 2.0 — see LICENSE.