italix/rules

Validation vocabulary and executor: machine error codes rather than prose, and a third verdict — unknown — for the checks it cannot honestly settle

Maintainers

Package info

github.com/italix-net/rules

pkg:composer/italix/rules

Transparency log

Statistics

Installs: 2

Dependents: 2

Suggesters: 2

Stars: 0

Open Issues: 0

4.0.0 2026-08-30 07:31 UTC

This package is not auto-updated.

Last update: 2026-08-31 06:13:03 UTC


README

PHP Version License

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

Formatsemail, 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.

Deferredunique 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.