Repository navigation
Phase 1: the PHP extension platform (events, registry, regex builder, commands) - #3
Merged
Merged
Conversation
The floor is illuminate ^13.0 only, so every branch guarding older Laravel was dead weight that implied a compatibility story the package does not have: contains()'s pipe-string fallback and its serializer, doesntContain()'s always-false RuntimeException guard, and the string-class-comparison workaround for rule classes that autoload fine on every supported version. requiredUnless()/prohibitedUnless() with a closure/bool KEEP their branch - a closure has no field name, so the rule object is the only spelling - but the misleading "only exists in Laravel 12+" comments are gone and both now construct RequiredUnless/ProhibitedUnless directly (verified: both classes take Closure|bool) instead of inverting into the If twins. getEachRules() - an exact alias of getEachKeyedRules() - is removed, and with it the migration-patterns note that had invented a 1.24/1.25 deprecation history for a package that had never passed v0.1. FluentSchema::anyOf() loses a @throws line describing an exception it never threw.
The §5.6 seams: RuleSetCompiling fires before a run compiles, with MUTABLE rules, messages, attributes and data - the generalized JsValidationRulesProcessing role, letting a consumer or sibling package inject cross-cutting rules without owning the call site; mutations apply to that run only and never stick to the instance. ValidationStarting observes the final shape; a run then fires exactly one of ValidationCompleted (with the validated data) or ValidationFailed (with the errors and exception). Failed is the notification seam: the package never notifies anyone - a consumer listens and routes to its own notifier under its own throttling policy, per the failure-handling standard's central-reporting rule. before() transforms data ahead of validation. after() carries Laravel's own after() semantics - the callback receives the validator and may add errors, turning a pass into a failure. That contract is a whole-run concern the per-item fast paths cannot honour, so registering any after callback routes the run through one vanilla Laravel validator (Internal\VanillaAfterRoute): full wildcard support, byte-identical verdicts, no shortcuts. Hooks trade speed for the full feature; a rule set without them keeps the fast engine. Every test drives a real listener changing an outcome, not just assertDispatched.
Hand-written regex is where validation goes wrong - the P6/P7 class was nothing but anchored patterns missing D. Regex::build() makes the common vocabulary readable and emits the safety a hand-written pattern forgets: anchored by default (unanchored() opts out), literals escaped, D always on, and the catastrophic-backtracking shape - an unbounded quantifier nested in another - refused unless dangerouslyUnbounded() says so by name. compile() probes the pattern so one PCRE refuses fails at definition time with the author present, not at validation time with a user in front of the form. The builder is never required. matches() takes a raw string (undelimited gains delimiters + D; delimited is verbatim with its own flags - the same contract as Laravel's regex:), a Regex, or a builder closure, all producing one rule; regex() keeps its exact string behaviour and merely widens to accept the other two. A team that already has a pattern just uses it.
Check answers one-off boolean guards - Check::iban($v), Check::slug($v) - where building a validator is ceremony. Every method is explicit, no __callStatic: an IDE completes them, static analysis sees them, and a typo is an unknown method instead of a runtime guess, which is the difference from the legacy is*() magic this recovers. Named guards cover the pure tier only; a boolean that quietly queried a database would be the footgun the class exists to avoid, and Check::rule() is the knowing escape hatch. Check::regex() carries the same pattern contract as matches(). Validation::fake() records RuleSet outcomes over the new event lifecycle for a consumer's tests - a recorder, not an interceptor: validation still runs for real, and the fake only remembers what happened (assertValidated / assertFailed / assertNothing*). RuleSet::toSchema() is the one-call bridge to the wire schema, so a PHP consumer never touches RuleExporter directly. It fails fast with the install command when laranail/validation-js is absent - an empty schema would read as "nothing to check" in a browser. validation-js joins require-dev (dev-main over the org's VCS pattern, mirroring its own dev-dependency on this package), which lets the positive path be tested here including the parameter-stripping guarantee holding through the new surface.
RuleRegistrar is the single registry the alias wiring, the console tooling and the docs generation all read. The package's own rules are DISCOVERED from src/Rules rather than hand-listed - a hand-kept list is exactly the count drift the doc tests exist to catch - and two consumer seams merge in: register(MyRule::class, alias: 'acme_thing', factory: ...) from a provider, and the laranail.validation.rules container tag for packages that prefer wiring over calls. Consumer aliases ride the same extension mechanism as the package's own, used AS GIVEN, because vendor-scoping them is the consumer's obligation under the org naming convention. Bound singletonIf, and that found a real bug in the singleton spelling: re-registering the provider (the laranail/email ordering scenario the provider tests pin) drops a resolved singleton's instances on rebind, which would have silently discarded every registration a consumer had already made. Browser opt-in needs no registry call at all - implementing ClientCheckable is the whole contract, proven end to end through toSchema().
Three commands under the org's namespaced shape, on the laranail/console base (whose symfony/console ^8 floor Laravel 13 already satisfies), with SupportsNamespacedNames carrying the `::` past Symfony's name validator - the tests dispatch through the full names to prove it. `rules` lists the LIVE registry - families, alias suffixes, browser-checkability, counts - so the answer is what the application actually has, not what a doc claims. `doctor` is the boot-health surface the failure-handling standard asks for: each check states expected vs found, WARNs report tolerated reduced capability (missing ext-intl, absent laranail/phone), FAILs exit non-zero for deploy gates - an enabled alias config with an empty prefix, the bare-generic-name collision hazard, is the pinned example. `benchmark` fronts the repo's harness honestly: benchmark.php is export-ignored from the Composer archive, so the command runs it in a checkout and says exactly that anywhere else, through Symfony Process rather than a forbidden passthru. laranail/console and symfony/process join require; the console package rides the org's VCS resolution like every laranail dependency.
Everything under Internal\ and the optimizer machinery now carries a class-level @internal, the README's Stability section states the stable list 1.0 will cover, and PublicApiBoundaryTest enforces the line in both directions - internal namespaces must be marked, the stable surface must not be (class-level; a stable class may still mark one method). The SemVer promise becomes a property of the code rather than a paragraph. The #[FluentRules] question (§14.13) is decided and recorded where the next reader will look, in the attribute's own docblock: KEPT - its inertness is the design, and dropping a documented external-tooling contract at 1.0 would be a silent break for exactly the tools it serves. The phase's exit criterion ships as a working example: AcmeServiceProvider - one consumer provider that registers a rule with a vendor-scoped alias through the registry, binds the TermList dataset contract with its own data, and listens on ValidationFailed. ConsumerExtensionTest proves all three work through the package's ordinary surfaces without touching a core file. UPGRADING carries the phase's two breaks (getEachRules() removal, the Laravel-11 branch removals).
Marking the whole optimizer set @internal contradicted four classes' own contracts, and the static analyzer's internal-tag enforcement proved it within minutes: BatchDatabaseChecker carries the DOCUMENTED boot-time batch knob, FluentValidator is subclassed (our own testing kit reads its constants), OptimizedValidator reaches consumers through a FormRequest's validator instance, and PreparedRules is the public prepare()'s return type. A class-level @internal on any of them breaks a surface someone is told to use. Those four stay reachable - their innards remain non-contractual - and the enforced internal list holds what is genuinely internal: the compilers, reducers, expander, memoizer and precomputed verifier, plus everything under Internal\ and FastCheck\. The boundary test records the reasoning where the next person will extend the list.
Benchmark results
|
There was a problem hiding this comment.
Pull request overview
Implements Phase 1 of the package’s extension platform by introducing a RuleSet lifecycle/event seam, a centralized rule registry, a safe-by-construction regex builder, and new console commands—while removing legacy compatibility branches and documenting the resulting breaking changes and stability boundary.
Changes:
- Added RuleSet lifecycle events plus
before()/after()hooks and aValidation::fake()recorder for consumer testing. - Introduced
RuleRegistrarfor rule discovery/registration and wired newlaranail::validation.*console commands. - Added
Regexbuilder and expanded fluent string-rule regex APIs; removed legacy Laravel-11 compatibility branches and thegetEachRules()alias.
Reviewed changes
Copilot reviewed 37 out of 37 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| UPGRADING.md | Documents breaking removals and compatibility-branch deletions. |
| tests/RuleSetEventsTest.php | Exercises new RuleSet lifecycle events and hooks end-to-end. |
| tests/RuleRegistrarTest.php | Validates rule discovery, consumer registration, tagging, and alias wiring. |
| tests/RegexBuilderTest.php | Covers Regex::of() / Regex::build() and fluent-rule integration. |
| tests/PublicApiBoundaryTest.php | Enforces @internal boundary vs stable surface list. |
| tests/FluentRuleTest.php | Updates tests to reflect removal of getEachRules(). |
| tests/Fixtures/Registry/EvenNumber.php | Adds a minimal consumer-authored rule fixture. |
| tests/Fixtures/Consumer/AcmeServiceProvider.php | Demonstrates consumer-side extension via provider wiring. |
| tests/ConsumerExtensionTest.php | End-to-end consumer extension criterion (rule, dataset, listener). |
| tests/ConsoleCommandsTest.php | Verifies new commands resolve/dispatch and basic behavior. |
| tests/CheckAndFakeTest.php | Covers Check statics, Validation::fake(), and toSchema() guard. |
| src/WildcardExpander.php | Marks optimizer machinery as @internal. |
| src/ValidationServiceProvider.php | Binds RuleRegistrar, registers new commands, merges alias factories. |
| src/Validation.php | Introduces static Validation::fake() accessor. |
| src/Testing/ValidationFake.php | Implements fake recorder via RuleSet lifecycle events. |
| src/Support/RuleRegistrar.php | Adds registry for discovered + registered + tagged rules and alias factories. |
| src/RuleSet.php | Adds events, hooks, after-route, and toSchema() export surface. |
| src/Regex.php | Adds safe-by-construction regex wrapper/builder implementation. |
| src/PrecomputedPresenceVerifier.php | Marks optimizer machinery as @internal. |
| src/Internal/VanillaAfterRoute.php | Implements vanilla-validator route for after() semantics. |
| src/FluentSchema.php | Removes misleading @throws about AnyOf availability. |
| src/FluentRules.php | Records decision to keep inert #[FluentRules] marker. |
| src/Events/ValidationStarting.php | Adds “starting” lifecycle event class. |
| src/Events/ValidationFailed.php | Adds “failed” lifecycle event class. |
| src/Events/ValidationCompleted.php | Adds “completed” lifecycle event class. |
| src/Events/RuleSetCompiling.php | Adds mutable “compiling” mutation seam event class. |
| src/Commands/RulesCommand.php | Adds registry listing command. |
| src/Commands/DoctorCommand.php | Adds wiring/health diagnostic command. |
| src/Commands/BenchmarkCommand.php | Adds benchmark harness wrapper command. |
| src/Check.php | Adds explicit static boolean guards over pure rule tier. |
| src/Builder/Nodes/StringRule.php | Widens regex() and adds matches() to accept Regex/closures. |
| src/Builder/Nodes/ArrayRule.php | Removes getEachRules() and legacy Laravel-11 compatibility branches. |
| src/Builder/Concerns/HasFieldModifiers.php | Removes Laravel-11 class-string comparison workaround; updates unless rules. |
| resources/boost/skills/laranail-validation/references/migration-patterns.md | Updates migration guidance to reflect getter removal and correct history. |
| README.md | Defines stable surface vs @internal boundary and references enforcement. |
| phpstan-baseline.neon | Updates cognitive complexity baseline for new RuleSet seam code. |
| composer.json | Adds new runtime deps (laranail/console, symfony/process) and dev VCS dep. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+147
to
+151
| // Consumer-registered aliases ride the same extension mechanism. | ||
| // Their names are used AS GIVEN — the consumer owns vendor-scoping | ||
| // them (`acme_thing`), per the org naming convention: the extension | ||
| // map is a flat registry where the last writer silently wins. | ||
| $factories = RuleAliases::map(); |
Comment on lines
+21
to
+38
| final class ValidationFake | ||
| { | ||
| /** @var list<array<string, mixed>> Validated payloads of passing runs. */ | ||
| private array $completed = []; | ||
|
|
||
| /** @var list<MessageBag> Error bags of failing runs. */ | ||
| private array $failed = []; | ||
|
|
||
| public function __construct() | ||
| { | ||
| Event::listen(ValidationCompleted::class, function (ValidationCompleted $event): void { | ||
| $this->completed[] = $event->validated; | ||
| }); | ||
|
|
||
| Event::listen(ValidationFailed::class, function (ValidationFailed $event): void { | ||
| $this->failed[] = $event->errors; | ||
| }); | ||
| } |
Comment on lines
+64
to
+75
| $samples = [ | ||
| 'models_exist' => [User::class], | ||
| 'authorized' => ['view', User::class], | ||
| 'parity' => ['even'], | ||
| 'vendor_identifier' => ['aws_region'], | ||
| 'national_identifier' => ['nl'], | ||
| ]; | ||
| $index = []; | ||
|
|
||
| foreach (RuleAliases::map() as $suffix => $factory) { | ||
| $index[$factory($samples[$suffix] ?? [])::class] = $suffix; | ||
| } |
Comment on lines
+57
to
+68
| public function register(string $rule, ?string $alias = null, ?Closure $factory = null): self | ||
| { | ||
| if (! in_array($rule, $this->registered, true)) { | ||
| $this->registered[] = $rule; | ||
| } | ||
|
|
||
| if ($alias !== null && $factory !== null) { | ||
| $this->aliasFactories[$alias] = $factory; | ||
| } | ||
|
|
||
| return $this; | ||
| } |
imanimanyara
added a commit
that referenced
this pull request
Aug 24, 2026
The post-push rector run rewrote chained regex expectations by swapping the head subject while leaving ->toBe(1) behind, and converted ->and() links to ->toMatch() without changing their subject — four assertions became failing or nonsensical, and the run that would have caught it sat unexecuted at action_required, so the breakage merged with PR #3. Restore the four assertions in the toMatch() style, skip RectorPest\Rules\UseToMatchRector (0.2.17 mishandles chains), and apply the Pint fixes the bot's ordering left behind. Full suite green: 5,919 tests, 0 failures.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The plan's Phase 1 — the extension seams consumers build on, plus the 1.0 legacy removal. Nine commits, each slice red-first and individually green.
contains()pipe-string fallback,doesntContain()'s unreachable throw, the string-class-comparison workaround),getEachRules(), the fabricated 1.24/1.25 deprecation note,FluentSchema::anyOf()'s false@throws. TherequiredUnless()/prohibitedUnless()closure branch is KEPT per the plan's trap note — verified L13 takes `ClosureRuleSetCompiling(mutable, per-run only),ValidationStarting, exactly-one-ofCompleted/Failed(the notification seam — the package never notifies).before()transforms data;after()carries Laravel's own error-adding semantics by routing through one vanilla validator when registered — fidelity over fast paths, stated not hidden. Every test drives a real listener changing an outcomeRuleRegistrar: package rules discovered fromsrc/Rules, consumerregister(class, alias:, factory:), and thelaranail.validation.rulescontainer tag. Found a real bug on the way:singleton()on provider re-registration drops resolved instances —singletonIfkeeps consumer registrations aliveRegex::of()/Regex::build()— anchored by default, literals escaped,Dalways (the P6/P7 class made unexpressible), nested-unbounded refused withoutdangerouslyUnbounded(), compile-time PCRE probe.matches()takes string/Regex/closure; raw patterns stay first-classCheck(explicit statics, pure tier only,Check::rule()escape hatch),Validation::fake()(recorder, not interceptor),RuleSet::toSchema()(one-call wire export; fails fast with the install command absent validation-js — now in require-dev via VCS, so the positive path and the stripping guarantee are tested here)laranail::validation.rules/.doctor/.benchmarkon thelaranail/consolebase, dispatched by their full::names in tests. Doctor is the queryable health surface: WARN for tolerated degradation, FAIL exits non-zero. New requires:laranail/console ^0.1,symfony/process@internalboundary enforced by an arch test in both directions — and corrected by the analyzer itself: four classes with consumer-facing contracts (the batch knob, the subclassable validators,prepare()'s return type) stay reachable, with the reasoning recorded in the test.#[FluentRules]: KEPT, decision recorded in its docblock. README states the 1.0 stable listAcmeServiceProvider+ConsumerExtensionTest: one consumer provider adds a rule with a vendor-scoped alias, binds a dataset contract, and listens on the event seam — all through ordinary surfaces, no core editDeferred with rationale (in the audit): the §5.2.1 dataset contracts land in Phase 2 with their consuming rules — the mechanism is established and consumer-proven via
TermListhere.Gates
Pest 5,919 / 0 failed · PHPStan level max 0 errors (RuleSet baseline 135 → 141, documented) · Pint clean · benchmark path untouched by design (no optimizer changes).
Note:
tag currencywill report red until v0.1.1 is tagged — pre-existing, by design, unrelated to this PR.