larulefy
v1.2.0
Published
Laravel-style validation for JavaScript
Maintainers
Readme
laRulefy
Laravel-style validation for JavaScript — with real instance isolation.
Table of contents
- Key features
- Why laRulefy
- Installation
- Requirements
- Module formats & CDN usage
- Compatibility
- Quick start — vanilla JS
- Quick start — Vue
- Quick start — React
- Quick start — Angular
- Rule syntax reference
- Custom rules
- Attribute labels
- Configuration reference
- Internationalization
- API reference
- Framework support
- TypeScript support
- Contributing
- License
Key features
- Real per-instance isolation — every
createValidator(createXRule)call (and everyuseValidator()/AngularValidatorcall in the adapters) allocates a brand-new closure. Two forms on the same page, even using the identical rule declaration, can never see each other's errors or values. No module-level or singleton state anywhere in the core. - Laravel-style rule syntax — declare rules with the same pipe syntax
Laravel uses (
'required|min:3|email'), evaluated by a small built-in parser (parseRuleString.js). - Laravel-style messages — built-in and custom messages use Laravel's
:attribute/:min/:max-style placeholders, plus optional human-readable attribute labels (Laravel'sattributes()convention). - Async-capable custom rules — a custom
Ruleclass'spasses()may return abooleanor aPromise<boolean>, so an API-backed check (e.g. uniqueness) is a first-class citizen, not a workaround. - Layered configuration — global defaults (
customMessages,customRules,attributes) set once inlaRulefy.config.jsare available to every validator, and any singlecreateXRule.jsfile can override them locally without mutating the shared global config or leaking into a sibling instance. - Internationalization — inject a translation
table per locale, keyed by rule name so it applies everywhere that rule
is used with zero per-field repetition; a runtime
localeswitch reaches already-created validators on their nextvalidate()call, no remount needed. English is the default and requires no configuration. - First-class adapters for Vue, React, and Angular — thin reactivity + lifecycle wrappers only; none of them reimplement validation logic, and all three are exercised by an explicit two-instances-never-share-state isolation test.
- Framework-agnostic, dependency-free core —
createValidator()works in plain Node or the browser with nothing else installed. Vue/React/ Angular are all optional peer dependencies. clearOnUnmountlifecycle integration — opt-in automaticreset()on component teardown across all three adapters (VueonUnmounted, React effect cleanup, AngularngOnDestroy).- ESM, CJS, and UMD builds — install from npm for a bundler, or drop a
<script>tag pointing at the UMD build for zero-build-step usage. - 13 built-in rules covering the common Laravel validation basics
(
required,email,min,max,size,numeric,string,boolean,confirmed,in,regex,array,nullable) — see the full reference. - Fully tested — 104 tests across the core and all three adapters, including a real install-matrix compatibility audit (not just changelog reasoning) for each framework's peer dependency floor.
Why laRulefy
Most JS validation libraries keep their state — rule registrations, error bags, sometimes even in-flight validation state — in module-level or otherwise shared storage that's imported once and reused everywhere. That's fine until a page has two forms on it that happen to use the same rule names. Then a change in one form's error state can bleed into the other, because both forms were quietly reading and writing the same shared bucket the whole time.
laRulefy makes that class of bug structurally impossible. Every
createValidator(createXRule) call allocates a brand-new closure — errors,
current values, everything — and nothing produced by one call is reachable
from another. Two components on the same page, even two instances of the
same component using the exact same rule declaration, get fully independent
validators with no extra bookkeeping required. Rule definitions
(required, email, and friends) are pure functions and safe to share; the
state produced by validating one specific form is never shared, ever.
The declaration style itself is deliberately modeled on Laravel: instead of
writing rules inline, you write a dedicated createXRule.js file — the
direct analogue of a Laravel FormRequest — and hand it to
createValidator() the way a controller type-hints a FormRequest
subclass.
Installation
npm install larulefyPackage page: npmjs.com/package/larulefy
Vue, React, and Angular are all optional peer dependencies — only
needed if you import larulefy/vue, larulefy/react, or larulefy/angular
respectively. The core has zero runtime dependency on any framework.
Requirements
- Node.js: any version with native ESM support (Node 14.13+ / any
currently-maintained Node release) to build from source; the published
package ships pre-built
dist/output, so consuming it doesn't require any particular Node version beyond what your own bundler/runtime needs. - Browser: any evergreen browser — the core and adapters use only standard ES2020+ syntax, no browser-specific APIs.
- Framework versions (only relevant if you use an adapter): see Compatibility for the verified minimum version of Vue/React/Angular.
Module formats & CDN usage
npm run build (via rollup.config.js) produces three formats for the
core, and an ESM build per adapter:
| File | Format | Use case |
|---|---|---|
| dist/larulefy.esm.js | ESM | import { createValidator } from 'larulefy' (bundlers, modern Node) |
| dist/larulefy.cjs.js | CommonJS | require('larulefy') |
| dist/larulefy.umd.js | UMD, global laRulefy | <script> tag, no build step |
| dist/adapters/vue.esm.js | ESM | larulefy/vue |
| dist/adapters/react.esm.js | ESM | larulefy/react |
| dist/adapters/angular.esm.js | ESM | larulefy/angular |
Zero-build-step usage straight from a <script> tag (e.g. via a CDN that
serves npm packages):
<script src="https://unpkg.com/larulefy/dist/larulefy.umd.js"></script>
<script>
const user = laRulefy.createValidator({
rules: { email: 'required|email' },
})
user.validate({ email: '' }).then(() => console.log(user.email))
</script>The adapter builds (Vue/React/Angular) are ESM-only, since every framework they target requires a module-aware bundler anyway.
Compatibility
The Vue adapter (larulefy/vue) uses only reactive, onUnmounted, and
getCurrentInstance — all present since Vue's first stable 3.0.0 release —
so the verified floor is vue@^3.0.0, with no later API requirement pushing
it higher.
That floor was verified by actually running test/adapters/vue.test.js
against installed copies of each version below, not just reasoned about:
| Vue version | Result | |---|---| | 3.0.0 (earliest 3.x) | ✅ Passing | | 3.2.0 | ✅ Passing | | 3.4.0 | ✅ Passing | | 3.5.39 (latest 3.x) | ✅ Passing |
Vue 2 is not supported. The adapter is built on the Composition API,
which Vue 2 doesn't have natively; there's no @vue/composition-api shim
or compatibility path in v1.
The React adapter (larulefy/react) uses only useRef, useReducer, and
useEffect — all part of the original hooks release, so the verified floor
is react@^16.8.0.
That floor was verified by actually running the adapter's isolation/behavior
test against installed copies of each version below (via react-test-renderer,
pinned to the matching React version per install), not just reasoned about:
| React version | Result | |---|---| | 16.8.0 (earliest hooks release) | ✅ Passing | | 17.0.2 | ✅ Passing | | 18.3.1 | ✅ Passing | | 19.2.7 (latest at time of testing) | ✅ Passing |
On 19.2.7, react-test-renderer itself logs a deprecation notice (React's
own test-renderer package is being phased out in favor of
@testing-library/react/browser-based testing) — that's a note about the
test tool, not the adapter; the adapter itself has no dependency on
react-test-renderer and works identically across all four versions above.
The Angular adapter (larulefy/angular) uses Injectable, InjectionToken,
Injector, inject, and signal from @angular/core. signal() itself
has been stable since Angular 17.0.0 — but that is not what sets the
verified floor below.
That floor was verified the same way as Vue and React: actually running
test/adapters/angular.test.js against installed copies of each version
below in isolated scratch installs, not just reasoned about from the
signals changelog:
| Angular version | Result |
|---|---|
| 16.2.12 (signal() in developer preview) | ❌ Failing |
| 17.0.0 (signal() stabilized) | ❌ Failing |
| 18.0.0 | ❌ Failing |
| 19.0.0 | ❌ Failing |
| 20.0.0 | ✅ Passing |
| 22.0.6 (latest at time of testing) | ✅ Passing |
Verified floor: @angular/core@^20.0.0. Every version from 16 through
19 fails identically at TestBed.createComponent() with
NullInjectorError: No provider for InjectionToken compilerOptions! — a
TestBed/JIT-compiler wiring issue combining platformBrowserTesting()
with a Vitest + happy-dom environment, unrelated to signal(), which
Angular 20's TestBed rework resolves. The original ^16.0.0 guess
(reasoned only from "Signals require 16+") undershot the real floor by
four majors — this is exactly why Part 13 requires an actual install
matrix instead of changelog reasoning alone.
@angular/compiler, @angular/platform-browser, and @angular/common
must all be pinned to the exact same version as @angular/core (their own
peerDependencies enforce this at 20.0.0+); zone.js@^0.16.2 and
rxjs@^7.8.2 (this repo's devDependency versions) both satisfy the
verified floor's peer ranges.
Quick start — vanilla JS
// createUserRule.js — pure declaration, no runtime state
export default {
rules: {
name: 'required|min:3',
email: 'required|email',
age: 'nullable|numeric|min:18',
},
messages: {
'name.required': 'Please enter your name.',
},
}import { createValidator } from 'larulefy'
import createUserRule from './createUserRule.js'
const user = createValidator(createUserRule)
await user.validate({ name: '', email: 'not-an-email' })
console.log(user.name) // "Please enter your name."
console.log(user.email) // "The email must be a valid email address."
user.reset()
console.log(user.name) // ""validate() is async because custom rules are allowed to do async work
(e.g. an API call to check uniqueness) — see Custom rules.
Quick start — Vue
<script setup>
import { ref } from 'vue'
import { useValidator } from 'larulefy/vue'
import createUserRule from './createUserRule.js'
const name = ref('')
const email = ref('')
const user = useValidator(createUserRule)
function currentValues() {
return { name: name.value, email: email.value }
}
async function onSubmit() {
const passed = await user.validate(currentValues())
if (passed) {
// proceed — e.g. submit to an API
}
}
function onClear() {
name.value = ''
email.value = ''
user.reset()
}
</script>
<template>
<form @submit.prevent="onSubmit">
<label>
Name
<input v-model="name" @blur="user.validate('name', currentValues())" />
<span v-if="user.name" class="error">{{ user.name }}</span>
</label>
<label>
Email
<input v-model="email" @blur="user.validate('email', currentValues())" />
<span v-if="user.email" class="error">{{ user.email }}</span>
</label>
<button type="submit">Sign up</button>
<button type="button" @click="onClear">Clear</button>
</form>
</template>useValidator(createXRule) must be called once per component, directly
inside setup()/<script setup> (the normal composable rule). That's
enough for isolation — each setup() call gets its own instance, the same
way it does in plain JS. This works with no other setup beyond importing
your laRulefy.config.js once at the app's entry point.
Quick start — React
import { useState } from 'react'
import { useValidator } from 'larulefy/react'
import createUserRule from './createUserRule.js'
function SignupForm() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const user = useValidator(createUserRule)
async function onSubmit(e) {
e.preventDefault()
const passed = await user.validate({ name, email })
if (passed) {
// proceed — e.g. submit to an API
}
}
function onClear() {
setName('')
setEmail('')
user.reset()
}
return (
<form onSubmit={onSubmit}>
<label>
Name
<input
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={() => user.validate('name', { name, email })}
/>
{user.name && <span className="error">{user.name}</span>}
</label>
<label>
Email
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => user.validate('email', { name, email })}
/>
{user.email && <span className="error">{user.email}</span>}
</label>
<button type="submit">Sign up</button>
<button type="button" onClick={onClear}>Clear</button>
</form>
)
}useValidator(createXRule) must be called directly in the component's
function body (the normal rule-of-hooks call site) — the core instance is
created exactly once per component instance via useRef, never re-created
on re-render, and reading user.<field> after validate()/reset() always
reflects the latest state on the next render. This works with no other setup
beyond importing your laRulefy.config.js once at the app's entry point.
Quick start — Angular
// signup-form.component.ts
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { AngularValidator, CREATE_X_RULE } from 'larulefy/angular'
import createUserRule from './createUserRule.js'
@Component({
standalone: true,
selector: 'signup-form',
imports: [FormsModule],
providers: [
{ provide: CREATE_X_RULE, useValue: createUserRule },
AngularValidator,
],
template: `
<form (ngSubmit)="onSubmit()">
<label>
Name
<input [(ngModel)]="name" name="name" (blur)="user.validate('name', { name, email })" />
<span *ngIf="user.name()" class="error">{{ user.name() }}</span>
</label>
<label>
Email
<input [(ngModel)]="email" name="email" (blur)="user.validate('email', { name, email })" />
<span *ngIf="user.email()" class="error">{{ user.email() }}</span>
</label>
<button type="submit">Sign up</button>
<button type="button" (click)="onClear()">Clear</button>
</form>
`,
})
export class SignupFormComponent {
user = inject(AngularValidator)
name = ''
email = ''
async onSubmit() {
const passed = await this.user.validate({ name: this.name, email: this.email })
if (passed) {
// proceed — e.g. submit to an API
}
}
onClear() {
this.name = ''
this.email = ''
this.user.reset()
}
}Two things are different from the Vue/React adapters, both deliberate:
- Each field is a Signal, not a plain property — read it as
user.name(), a function call, notuser.name. Angular is moving toward Signals for reactivity outside of Zone.js (zoneless apps), so the adapter exposes Signals directly rather than a Zone.js-dependent plain property that would silently stop updating templates in a zoneless app. AngularValidatormust be listed in the consuming component's ownproviders: [...]array, alongside aCREATE_X_RULEprovider for your rule declaration — never register it at the root injector (providedIn: 'root') or anywhere above component scope (anNgModule, a lazy route's environment injector). Component-levelprovidersis the only level in Angular's DI hierarchy that creates a fresh instance per component instance rather than per some broader shared scope — see the comment aboveAngularValidatorinsrc/adapters/angular.jsfor the full comparison against the alternatives. Get this wrong and two components on the same page silently share one instance's errors, the exact bug this library exists to prevent.
For consumers who'd rather not lean on Angular's DI at all, a plain factory is also available as an escape hatch:
import { createAngularValidator } from 'larulefy/angular'
import createUserRule from './createUserRule.js'
export class SignupFormComponent {
user = createAngularValidator(createUserRule)
}clearOnUnmount is wired to ngOnDestroy() — when true, AngularValidator
calls reset() automatically when the providing component is destroyed,
the same role Vue's onUnmounted and React's effect-cleanup play.
Rule syntax reference
Rules use Laravel's pipe syntax:
rules: {
name: 'required|min:3',
email: 'required|email|max:255',
}Built-in rules (v1):
| Rule | Description |
|---|---|
| required | Value must be present (non-empty string/array, not null/undefined). |
| nullable | If the value is empty, skip the rest of that field's pipeline. |
| email | Must be a valid email address. |
| min:n | Minimum string length / array length / numeric value. |
| max:n | Maximum string length / array length / numeric value. |
| size:n | Exact string length / array length / numeric value. |
| numeric | Must be a number or numeric string. |
| string | Must be a string. |
| boolean | Must be true/false/0/1/'0'/'1'. |
| confirmed | Must match a sibling <field>_confirmation value. |
| in:a,b,c | Must be one of the given values. |
| regex:pattern | Must match the pattern (raw source, or /pattern/flags). |
| array | Must be an array. |
Every rule except required and nullable passes automatically when the
value is empty — presence is required's job, matching Laravel's own
behavior.
Custom rules
For anything beyond a pure function — a uniqueness check against an API,
for example — a createXRule.js file can register a custom Rule class,
following Laravel's own Rule contract:
export class Unique {
async passes(attribute, value) {
const res = await fetch(`/api/check-email?email=${value}`)
return (await res.json()).available
}
message() {
return 'The :attribute has already been taken.'
}
}// createUserRule.js
import { Unique } from './rules/Unique.js'
export default {
rules: { email: 'required|email|unique' },
customRules: { unique: Unique },
}passes(attribute, value) may return a boolean or a Promise<boolean>
— async rules are fully supported, and validate() always returns a
promise so sync and async rules can be mixed freely. message() returns a
Laravel-style template string (:attribute is substituted automatically).
Custom rules registered globally via customRules in laRulefy.config.js
are available to every createXRule.js file without re-importing them; a
rule of the same name registered locally in a specific declaration takes
precedence for that declaration only.
Attribute labels
By default, :attribute in a message is substituted with the raw field
name (email, date_of_birth, ...). To show a friendlier, human-readable
label instead — Laravel's attributes() convention — set attributes on
the createXRule.js declaration, the global config, or both:
// createUserRule.js
export default {
rules: { email: 'required|email' },
attributes: { email: 'Email Address' },
}await user.validate({ email: '' })
console.log(user.email) // "The Email Address field is required."attributes in laRulefy.config.js sets a global default label per field
name, available to every createXRule.js file; a declaration's own
attributes overrides the global label for that declaration only, the
same layering rule messages/customMessages already follow.
Configuration reference
// laRulefy.config.js
import { defineConfig } from 'larulefy'
export default defineConfig({
locale: 'en',
fallbackLocale: 'en',
locales: {},
lang: 'js',
clearOnUnmount: true,
errorFormat: 'laravel',
customMessages: {},
customRules: {},
attributes: {},
})| Key | Description |
|---|---|
| locale | Active locale used to look up translated built-in/global messages (see Internationalization). Defaults to 'en', which needs no locales entry — the built-in rule messages already are the English table. |
| fallbackLocale | Locale to fall back to when locale's table is missing a translation for a given rule, before falling back to the built-in English message. Defaults to 'en'. |
| locales | Injected translation tables, keyed by locale then by rule name (required) or 'field.rule' for a field-specific override within that locale ('email.required'). Empty by default — English behavior is unchanged unless you opt in. |
| supportedLocales | Informational list of locales the app offers, e.g. to drive a language switcher. Defaults to 'en' plus the keys of locales; set it explicitly if you want a different list. |
| lang | Reserved placeholder, intentionally a permanent no-op — confirmed with the maintainer (2026-07-16) that it should ship this way rather than be defined or removed, so a future need distinct from locale has a home without a breaking change. It doesn't affect behavior. |
| clearOnUnmount | Consumed by framework adapters (not the core): when true, the Vue, React, and Angular adapters all call reset() automatically at that framework's unmount/destroy equivalent. |
| errorFormat | Message convention for built-in/default messages. Only 'laravel' is supported. |
| customMessages | Global message overrides, Laravel-style ('field.rule' → message string). Available as defaults to every instance; a declaration's own messages overrides these without mutating the global config. Checked before locale translations. |
| customRules | Global custom Rule registry, available to every createXRule.js file by name. |
| attributes | Global human-readable field labels substituted for :attribute in messages (see Attribute labels). Available as defaults to every instance; a declaration's own attributes overrides these without mutating the global config. |
Internationalization
By default every built-in rule message is English, and nothing changes unless you opt in. To support other languages, inject a translation table per locale — keyed by rule name, so one entry covers that rule for every field in the app, with no per-field repetition:
// laRulefy.config.js
import { defineConfig } from 'larulefy'
export default defineConfig({
locale: 'ar',
fallbackLocale: 'en',
locales: {
ar: {
required: ':attribute مطلوب.',
email: ':attribute يجب أن يكون بريدًا إلكترونيًا صالحًا.',
// 'field.rule' overrides a single field within this locale only:
'password.confirmed': 'تأكيد كلمة المرور غير متطابق.',
},
},
})Every createValidator/useValidator instance reads locale fresh on
each validate() call — switching locale at runtime with another
defineConfig({ locale: '...' }) call reaches instances that were already
created, with no remount needed. Resolution order per failing rule is:
a declaration's own messages → global customMessages → the active
locale's 'field.rule' entry → the active locale's rule-keyed entry →
the same two lookups against fallbackLocale → the built-in English
message.
API reference
| Call | Behavior |
|---|---|
| createValidator(createXRule) | Core factory. Returns a brand-new, fully isolated instance every call. |
| useValidator(createXRule) (from larulefy/vue) | Same instance, wrapped in Vue reactivity, with clearOnUnmount wired to the component lifecycle. |
| useValidator(createXRule) (from larulefy/react) | Same instance, wrapped in a React hook that re-renders the component on validate()/reset(), with clearOnUnmount wired to unmount cleanup. |
| AngularValidator + CREATE_X_RULE (from larulefy/angular) | Same instance, wrapped in an Angular DI-scoped service — provide both in the consuming component's own providers: [...], then inject(AngularValidator). clearOnUnmount wired to ngOnDestroy(). Fields are Signals: instance.field(). |
| createAngularValidator(createXRule) (from larulefy/angular) | Non-DI escape hatch — same AngularValidator shape, constructed directly without relying on Angular's component-tree injector. |
| instance.<field> | Flat read of that field's current error message, or '' if it has none. Angular: instance.<field>() (a Signal). |
| instance.validate(values) | Validates every declared field against values. Returns Promise<boolean>. |
| instance.validate('field') | Re-validates just that field, reusing the last-provided values. |
| instance.validate('field', values) | Updates the stored values, then validates just that field. |
| instance.reset() | Clears every field's error state and any stored values. |
| defineConfig(config) | Merges config over the library defaults and sets it as the active global config. |
Framework support
| Framework | Status |
|---|---|
| Vue | Supported now, via larulefy/vue (optional peer dependency). Requires vue@^3.0.0 — see Compatibility. Vue 2 is not supported. |
| React | Supported now, via larulefy/react (optional peer dependency). Requires react@^16.8.0 — see Compatibility. |
| Angular | Supported now, via larulefy/angular (optional peer dependency). Fields are exposed as Signals (instance.field()) rather than plain properties — see Quick start — Angular. See Compatibility for the verified version floor. |
| Svelte | Not yet — same as above. |
| Vanilla / no framework | Fully supported today — just use createValidator() directly, as shown above. |
TypeScript support
The source is plain JavaScript with JSDoc annotations (see the coding
standards: JSDoc over TypeScript, no build step for types). Editors that
understand JSDoc (VS Code, WebStorm, ...) get inline type hints and
autocomplete for every exported function automatically. There is no
bundled .d.ts declaration file yet — if you need strict .ts typings,
treat this as an open contribution area (see Contributing).
Contributing
git clone https://github.com/mohammedalbadry/laRulefy.git
cd laRulefy
npm install
npm test # run the full suite (core + Vue + React + Angular adapters)
npm run build- The core suite runs with zero framework installed — don't add Vue/
React/Angular as a dependency of anything under
test/core/. Framework-specific tests live undertest/adapters/<framework>.test.js, the only place that framework's package may be a test dependency. - The single most important test for any adapter change: mount two
component instances from the same
createXRule.jsdeclaration, put one into an invalid state, and assert the other is completely unaffected. See the existing isolation tests intest/adapters/for the pattern. - Run
npm testandnpm run buildbefore opening a PR. PRs welcome — bug reports, new built-in rules, and additional framework adapters (Svelte is on the radar, see Framework support) are all welcome.
License
MIT — see LICENSE.
