laranail/validation-js is not published to Packagist, so there is no registry-version badge to show: see Install.
Export your Laravel validation rules as a JSON schema and check them in the browser — with no round trip, no duplicated rule definitions, and no lying about what the client can actually decide.
Targets PHP ^8.4.1 on Laravel ^13, and Node >=22.6. The JavaScript has zero dependencies, runtime and dev.
A browser cannot check unique, exists or active_url. Libraries that quietly treat those as
passing show the user a green tick for input the server is about to reject.
This one is three-valued. A field is invalid, valid, or undetermined — carrying at least one rule only the server can decide:
const { valid, failures, undetermined } = validate(values, schema);A rule the exporter does not recognise goes to the server, including custom rules, package rules, and rules added by a future Laravel version. The cost of that default is a round trip; the cost of the other one is a false green tick.
composer require laranail/validation-js
npm install @laranail/validation-js@laranail/validation-js is published to GitHub Packages, not npm, while npm cannot take a publish
from this repository (npm/cli#9969). GitHub Packages asks
for a token even for a public package, so before npm install add a GitHub token with
read:packages to the project's .npmrc:
@laranail:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}Nothing is required after the two installs: ValidationJsServiceProvider is auto-discovered and
the npm package has zero runtime dependencies. Publish the config only when you want the
transport endpoints or different runtime defaults:
php artisan vendor:publish --tag=laranail::validation-js-configExport on the server:
use Simtabi\Laranail\ValidationJs\RuleExporter;
$schema = app(RuleExporter::class)->export(
(new StoreUserRequest())->rules(),
attributes: ['email' => 'Email address'],
);Check in the browser:
import { validate } from '@laranail/validation-js';
const result = validate({ email: '', age: '15' }, schema);
result.valid; // false
result.failures; // [{ field: 'email', rule: 'required', message: 'The Email address field is required.' }]
result.undetermined; // ['email'] — it also carries `unique`, which only the server can answerSubmit when valid is true, even if undetermined is non-empty. The server gives the real
answer; the browser just saves the user a round trip on the things it can be sure about.
The PHP suite runs Laravel's own validator over a grid of 511 rule-and-value combinations, records its verdicts, and writes them to a fixture. The JavaScript suite then has to reproduce every one.
That is the only honest way to claim the runner matches Laravel. Asserting it against hand-written expectations would prove it matches what its author believed Laravel does — and while building this, four of those beliefs turned out to be wrong:
| I assumed | Laravel actually |
|---|---|
email needs a dot in the domain |
accepts a@b — the default validation is RFC, not "has a TLD" |
max:5 compares "6" numerically |
compares string LENGTH unless a numeric rule is present |
contains:foo is a substring test |
asks whether the attribute ARRAY contains the value |
| an empty value skips every rule | runs implicit rules anyway — accepted on '' fails |
CI regenerates the fixture and fails if the committed copy disagrees, so the claim cannot rot into a statement about a past version of Laravel.
laranail/validation-js on Packagist writes the schema; @laranail/validation-js on npm reads
it. They are separate releases and neither waits for the other.
That is a property of the format, not a promise. Within a major schema version every change is additive: the runner ignores keys it does not recognise, degrades a rule whose parameters it cannot find to undetermined rather than guessing, and a renamed key is emitted under both names for as long as anyone is running the old code. A mismatch costs precision — some fields round trip that need not have — and never correctness.
The alternative, which this replaced, was a version check that fired on any change and sent whole forms to the server until both halves were upgraded in step. See Schema.
createValidator(form, schema, options) binds a real form with debounced eager validation, an
injectable renderer (ClassMapRenderer over plain-data presets — bootstrap5, tailwind,
bulma, vanilla), input-widget resolvers, a dual event channel (instance on() plus
bubbling laranail:* DOM events), wizard-step validate({ only }), server-422 setErrors(),
repeater-aware refresh(), and core-owned accessibility: aria-invalid, non-destructive
aria-describedby, a polite live region, and a role="alert" summary with focus management.
Everything is instance-scoped — two validators coexist on one page, attach is idempotent, and
destroy() is leak-free, all pinned by the Playwright suite. The engine stays zero-dependency
with a CI bundle budget (Layer 0 ≤ 8 KB min+gzip).
Where a framework owns the DOM, the library answers with state instead: HeadlessForm under a
React hook (@laranail/validation-js/react) and a Vue 3 composable (…/vue), with Alpine
(…/alpine) and declarative autoboot for Blade/HTMX/Turbo/Livewire pages (…/autoboot) on
the DOM-owning side, and …/debug console tracing that tree-shakes out by absence.
1.0 states what SemVer covers. Stable: the JS createValidator / createHeadless /
HeadlessForm and the Validator surface (validate, validateField, setErrors,
refresh, explain, on, use, registerRule, destroy), the framework adapters'
documented API, the shipped .d.ts types, the wire schema (Schema);
on the PHP side RuleExporter, SchemaFactory, RemoteRegistry, the events, the Blade
surface, the console commands, and the laranail.validation-js.* config keys. Not stable
(@internal, may change in a minor): RuleCatalogue, the HTTP controllers,
EngineIntrospection, and anything undocumented. A test enforces that every class is one or
the other. Deprecations post-1.0 are marked @deprecated with the replacement and removal
version, kept for at least one minor, removed only in the next major.
Browsers: Safari/iOS 15.4+, Chrome/Edge 93+, Firefox 92+ (declared in browserslist;
the floor is Object.hasOwn — see Installation). Accessibility:
the runtime's a11y behaviour (aria-invalid, non-destructive aria-describedby, polite live
region, role="alert" summary, focus management, reduced-motion) lives in core, is asserted
with axe in the browser suite, and is part of the stable contract.
- Installation — both halves, the VCS repositories, the subpath exports
- Getting started — one path from FormRequest to validated form
- Configuration — the
laranail.validation-js.*keys and per-instance options - Architecture — the layered design, and why the adapters are headless
- Schema — the wire contract both halves implement
- Transport — the three delivery tiers, the validate endpoint, and the remote channel
- Release — three version lines, one discipline
- Rules — what runs in the browser, what does not, and why
- Form runtime —
createValidator, scheduling, rendering, a11y, debug - Headless form — the DOM-free stateful facade under the adapters
- Adapters — the React hook and the Vue composable
- Bridges — autoboot, Alpine, and the Livewire rules
- Console commands —
export,doctor,parity
- React form · Vue form · Blade autoboot · Alpine · Livewire · Nova field
- Server errors · Wizard steps · Custom rule · Remote validation
- Upgrading — why the two packages upgrade separately, and what would change that
The two ideas at the centre of this — a client allow-list with an unknown-rule fallback to the server, and a positional-to-named parameter table — were established by proengsoft/laravel-jsvalidation. No code was carried over; that package is MIT and a derivative would inherit its attribution obligation. See CREDITS.md.
MIT © Simtabi LLC. See LICENSE.