Skip to content

Speed up validation of large wildcard payloads - #21

Merged
ahamed merged 1 commit into
mainfrom
feature/validator-performance
Nov 13, 2026
Merged

ahamed merged 1 commit into
mainfrom
feature/validator-performance

Conversation

@ahamed

@ahamed ahamed commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Validating a product with 180 variants (5,956 keys after wildcard expansion, the example/ stress payload) drops from ~950 ms to ~58 ms in the CLI, and from ~2.5 s to ~88 ms with Xdebug on. Three hot paths were fixed:

 Validator::make()
   RuleParser::process_rules()
-    for each expanded key: RuleFactory::make(rules)       # 180× per rule entry
+    for each rule entry:   RuleFactory::make(rules) once
+    later keys of the same entry: clone the instances   # each key still owns its rules

 Validator::passes()
+  if outcome stored: return it                          # validate() no longer runs twice
   for each key, for each rule
     apply(rule)
-      sync_custom_messages() → message() × every constraint the rule supports
+      sync_custom_messages() → skipped when no custom messages are involved
-        MessagesBag::get() rebuilds the whole message table
+        MessagesBag::get() reads the table built once
  • set_value() and sometimes() (when it adds rules) clear the stored outcome, so the next call validates again.
  • MessagesBag now merges config/messages.php recursively. Overriding validator.required.default used to wipe out every other validator default.
  • example/ gets a public GET /wp-json/framework/v1/validation-stress?variants=180[&invalid=1] route for exercising the validator end to end.
  • New main specs: validation-rule-parsing, validation-run-lifecycle, validation-messages. The two OpenSpec changes are archived.

Evidence

Stress payload (180 variants, 5,956 keys), CLI, PHP 8.4. main was measured in a worktree at 15f25bd; each figure is the mean of 2 runs:

main, Xdebug off branch, Xdebug off main, Xdebug on branch, Xdebug on
Parse (Validator::make) 577 ms 17 ms 1,557 ms 20 ms
passes() 177 ms 14 ms 495 ms 31 ms
validated() after passes() 198 ms 27 ms 515 ms 37 ms
Total 952 ms 58 ms 2,567 ms 88 ms

On main, validated() runs every rule again; on this branch it only builds the result. The playground web request (1,238 ms in passes() before) has not been re-measured.

Tests that failed before and pass now:

RuleParsingTest
  caller-supplied rule object on a wildcard key → distinct instance per key
ValidatorRunLifecycleTest
  validate() runs each rule once                           (was 2×)
  passes() then validated() runs the rules once            (was 2×)
  fails() then passes() → each error reported once         (was duplicated)
  sometimes() with a false callback keeps the outcome
  set_value() after a failed run → passes() is true again  (was stale errors)
CustomMessagesTest
  passing payload without custom messages → 0 message lookups   (was 2,157)
MessagesBagTest
  overriding one validator message keeps the other defaults
  overriding validator.string.max keeps validator.string.min

Tests that already passed were added to lock in the existing message and parsing behaviour: custom messages (flat, nested, placeholder, closure, one wildcard key, another field, rule reused across validators), Rule::when per validator, closures per key, and per-key errors.

composer test:unit: OK (980 tests, 2314 assertions). composer phpcs: 0 errors. The 3 line-length warnings it reports were already there (TemplateEngine.php, ValidationRule.php).

Merge Danger

Door: two-way

No public signatures change, and reverting the commit restores the old behaviour.

Blast Radius: validation

What consumers could notice:

  • get_rules() returns separate (cloned) instances for each expanded key. A rule object passed for a wildcard key used to be shared by all keys.
  • A repeat passes()/fails() returns the stored outcome. Code that writes $this->data or $this->rules directly in a Validator subclass must call reset_outcome().
  • A custom rule subclass whose default messages depend on $this->name, $this->value or $this->data must override sync_custom_messages(). No rule in src/ does this.
  • A plugin that overrode a whole message group to drop the defaults will now keep them.

🤖 Generated with Claude Code

Validating a product with 180 variants (5,956 keys after wildcard
expansion) took ~950 ms in the CLI and ~2.6 s with Xdebug on. It now
takes ~58 ms and ~88 ms. Three causes, all fixed:

- RuleParser parsed every expanded wildcard key separately. Each rule
  entry is now parsed once; later keys get shallow clones, so each key
  still owns its instances (parse: 577 ms -> 17 ms).
- Validator re-ran every rule: validate() ran passes() twice, and a
  repeat passes()/fails() duplicated errors. The outcome is now stored
  and reused; set_value() and sometimes() (when it adds rules) reset it
  (validated() after passes(): 198 ms -> 27 ms).
- Every rule check re-resolved default messages for all of the rule's
  constraints, each lookup rebuilding the MessagesBag table.
  MessagesBag::get() now reads the table built once, and
  sync_custom_messages() skips the work when no custom messages are in
  play (passes: 177 ms -> 14 ms).

Also fixes MessagesBag merging config/messages.php shallowly: overriding
one validator message wiped every other default in the group. It now
merges recursively.

Adds tests for wildcard parsing, run lifecycle, custom messages and the
messages bag; a public GET /validation-stress route in example/ to
exercise the validator; and the OpenSpec specs for validation-rule-parsing,
validation-run-lifecycle and validation-messages.

Timings: CLI, PHP 8.4, Xdebug off unless stated, mean of 2 runs, main
measured at 15f25bd.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ahamed
ahamed force-pushed the feature/validator-performance branch from 0ed3973 to 0ba675f Compare November 13, 2026 11:21
@ahamed
ahamed merged commit 31c08bf into main Nov 13, 2026
1 check passed
@ahamed
ahamed deleted the feature/validator-performance branch November 13, 2026 11:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant