Skip to content

Latest commit

 

History

History
198 lines (146 loc) · 10.3 KB

rules.md

File metadata and controls

198 lines (146 loc) · 10.3 KB

Rules

What runs in the browser

Presence (required, filled, present, nullable, sometimes), types (array, boolean, integer, numeric, string, json), size (between, digits, digits_between, max, min, size), format (alpha, alpha_dash, alpha_num, ascii, email, hex_color, ip, ipv4, ipv6, lowercase, mac_address, regex, not_regex, ulid, uppercase, url, uuid), sets (in, not_in), cross-field (accepted, confirmed, declined, different, same, gt, gte, lt, lte), conditional presence (required_if, required_if_accepted, required_if_declined, required_unless, required_with, required_with_all, required_without, required_without_all), substring (contains, doesnt_contain, ends_with, starts_with, and their doesnt_ forms), and numeric (decimal, multiple_of).

What does not, and why

Rule Needs
unique, exists the database
active_url DNS
current_password the session and a hash comparison
dimensions, image, mimes, mimetypes, extensions, file to read the file, which a browser can only approximate from a name
exclude, exclude_if, exclude_unless, exclude_with, exclude_without a different result model — see below

These are listed explicitly rather than merely omitted, because each is a case where a naive reading would put it on the client and be wrong.

Everything else absent from the client list is also server-side, including rules that do not exist. See Schema.

Four places the browser must match Laravel exactly

Each of these was wrong in the first implementation and was caught by the differential test, not by review.

email does not require a TLD. Laravel's default is egulias RFCValidation, which accepts a@b. A stricter browser check rejects input the server accepts — the worst direction, because the user cannot proceed and nothing tells them why.

A size rule's unit comes from the RULE SET, not the value. max:5 passes for "6" because without a numeric rule the size is the string length. Add numeric and the same input fails. Deciding from whether the value looks numeric gets it backwards.

contains is an array rule. It asks whether the attribute array contains the given values. contains:foo fails for the string 'a foo b', which is the opposite of what the name suggests.

An empty value does not skip every rule. Laravel runs its implicit rules regardless: accepted on '' fails rather than being skipped.

What happens when the two packages are out of step

They are separate packages and are meant to be upgraded separately, so the runner is built to lose precision rather than correctness when it meets a schema it cannot fully read.

  • A rule name it does not implement → that field is undetermined.
  • A parameter it cannot find → that ONE rule is undetermined, the rest of the field is still decided. Without this the coercions underneath turn a missing max into 0 and the rule silently becomes "no value is shorter than nothing", rejecting everything.
  • A top-level key added since it was published → ignored.
  • A different major schema version → the whole schema goes to the server. Reserved for a restructuring that cannot be expressed additively, and not expected to happen.

The cost is always a round trip, never a wrong answer. See Schema.

Absent, blank and null are three different things

The rule above is not "empty values are skipped", and reading it that way is how the runner came to pass four payloads the server rejects. Laravel's isValidatable draws a finer line, and the runner reproduces it:

The value is What runs
a blank string ('', ' ') implicit rules only
absent — the key is not in the payload implicit rules only
present, including null and [] everything

So ['age' => null] against integer fails, exactly as it does on the server. Treating a present null as "nothing to check" showed a green tick for input that was about to be rejected.

nullable is the opt-out, and it is narrower than it looks: only for null, and only for the non-implicit rules, so a required alongside it still fires. The decision belongs to the rule set — the value does not get to make it.

Two rules read presence directly rather than through their value, and both were wrong for the same reason. present asks whether the KEY exists, so {name: null} passes it. filled is required only when the key is there — "if you send it, send something" is not the same as requiring it.

confirmed, same and different are deliberately not implicit, though they read like they should be. Laravel's own $implicitRules omits them, and the consequence is visible: {other: 'x'} against field => same:other passes on the server, because an absent field never reaches the rule.

Why exclude_* stays on the server

Every other rule answers pass or fail. exclude_if does something else: it removes the field from validated() entirely, changing the SHAPE of the result rather than the verdict.

A runner that "supported" it would have to return a different data set, not a different answer — and an application that trusted a client-side exclusion would submit a payload it believed had been filtered. That is a larger change than a rule implementation, and faking it is worse than routing it.

Conditional presence

required_if and its family are decided in the browser, because every input they need — the value of another field in the same submission — is already there. Sending them to the server spends a round trip on the commonest dynamic-form case.

Two details worth knowing:

  • They are implicit rules. Their whole job is to decide whether an absent field should have been there, so skipping them on an empty value would skip exactly the case they exist for.
  • required_without and required_without_all are not symmetrical with the _with pair. required_without:a,b fires when ANY named field is missing; required_without_all:a,b only when ALL are. That asymmetry is Laravel's.

One limitation: Laravel converts true/false parameters when the dependent field is declared boolean, and the schema does not carry that declaration. A boolean value is therefore compared in both spellings rather than the declaration being guessed at.

Rules from laranail/validation

A rule OBJECT normally routes to the server: its logic is PHP that was never sent. Rules implementing Contracts\ClientCheckable are the exception — they advertise their own pattern, which the exporter ships as a regex or not_regex rule.

Today that is Slug, WithoutSpaces, SemVer, Subdomain, EthereumAddress, CaseStyle, Username, MonetaryAmount, VendorIdentifier, PostalCode, Latitude, Longitude and CssColor.

A rule may advertise more than one. Latitude is is_numeric plus a range, so its browser form is numeric and between:-90,90 — two native rules the runner already implements, rather than a regex contorted into a bounded numeric range. All advertised rules must pass.

If any one of them is unusable, the whole advertisement is dropped and the rule routes to the server. Exporting the usable half would check a field against a subset of its own rules and pass values the full set rejects — a green tick that is worse than a round trip.

PostalCode sends only the countries it was given — one pattern each, never the hundred-country table — and sends nothing at all when built with reference('country'), because which pattern applies is not knowable while exporting.

The contract returns native Laravel rule names and parameters rather than a JavaScript implementation, and that is the point: a hand-written twin of every rule would drift from the PHP one and disagree with the server in the cases nobody tested.

CssColor inlines the 150 named CSS colours into its pattern, about 2 KB. Leaving them out would mean a browser rejecting red.

No rule performing a checksum, a query or IO advertises one, and none should. A shape-only pattern for an IBAN would pass a mistyped account number in the browser and fail it on the server — the precise failure this whole design avoids. Both packages carry tests asserting those rules stay server-side.

Two things had to be right for this to work at all, and both fail safe:

  • Laravel wraps a rule object in InvokableValidationRule during explode(), so the exporter unwraps before looking for the contract. Without that the contract is unreachable and the server name is the wrapper's mangled FQN rather than the rule's.
  • An advertised rule name the runner does not implement is ignored, and the rule routes to the server as usual.

Wildcards

A schema field key is a pattern, not a key. items.*.email never appears in the submitted data: the runner expands it against what was actually sent and checks each concrete path, and a failure names that path — items.1.email, not items.*.email.

An empty or absent collection expands to nothing, so items.*.email => required passes for {"items": []}. Laravel behaves the same way: there is no item, so there is no field to require.

Nested wildcards work: rows.*.cols.*.v expands the second only once the first is concrete.

Cross-field rules resolve within the row first. Inside items.0.password, a same:password_confirmation means the sibling in that row, walking outward only if no such sibling exists. Resolving at the top level would compare every row against one shared field, which is not what the form means.

The expansion happens in the browser, not at export time, because the exporter describes a rule SET rather than one submission — it has no data to expand against. Letting Laravel's parser expand it there produced a field with an empty rule list, which the runner then read as "nothing to check".

A PHP regex that JavaScript cannot express

regex: patterns are translated from PCRE. When a pattern uses a construct JavaScript lacks — possessive quantifiers, recursion, some lookbehind — the translation returns null and the rule passes on the client. The server still checks it, and failing a valid value in the browser is the worse error.


← Docs index