Skip to content

Phase 1: the PHP extension platform (events, registry, regex builder, commands) - #3

Merged
imanimanyara merged 9 commits into
mainfrom
phase-1-extension-platform
Aug 24, 2026
Merged

imanimanyara merged 9 commits into
mainfrom
phase-1-extension-platform

Conversation

@imanimanyara

Copy link
Copy Markdown
Member

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.

Area What landed
§7.1 legacy removal The Laravel-11 branches (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. The requiredUnless()/prohibitedUnless() closure branch is KEPT per the plan's trap note — verified L13 takes `Closure
§5.6 events + hooks RuleSetCompiling (mutable, per-run only), ValidationStarting, exactly-one-of Completed/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 outcome
§5.2.2 registry RuleRegistrar: package rules discovered from src/Rules, consumer register(class, alias:, factory:), and the laranail.validation.rules container tag. Found a real bug on the way: singleton() on provider re-registration drops resolved instances — singletonIf keeps consumer registrations alive
§6.9 regex builder Regex::of()/Regex::build() — anchored by default, literals escaped, D always (the P6/P7 class made unexpressible), nested-unbounded refused without dangerouslyUnbounded(), compile-time PCRE probe. matches() takes string/Regex/closure; raw patterns stay first-class
§6.10 ergonomics Check (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)
§5.8 commands laranail::validation.rules / .doctor / .benchmark on the laranail/console base, 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
§12.1 + §14.13 @internal boundary 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 list
Exit criterion AcmeServiceProvider + 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 edit

Deferred 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 TermList here.

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 currency will report red until v0.1.1 is tagged — pre-existing, by design, unrelated to this PR.

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.
Copilot AI lite review requested due to automatic review settings August 24, 2026 00:51
@github-actions

Copy link
Copy Markdown

Benchmark results

Scenario Optimizations Native Laravel Optimized Speedup Δ vs base
Product import — 500 items, simple rules Wildcard, fast-check 179.4ms 3.4ms ~54x +6%
Nested order lines — 1000 orders × 5 line items Wildcard, fast-check (nested) 1289.7ms 19.0ms ~68x +0%
Event scheduling — 100 items, field-ref dates Wildcard, partial fast-check 28.7ms 1.3ms ~22x +0%
Article submission — 50 items, custom Rule objects Wildcard only 10.2ms 2.8ms ~4x +4%
Conditional import — 100 items, 47 conditional fields Wildcard, pre-evaluation 3817.1ms 50.5ms ~76x +0%
Login form — 3 fields, no wildcards Fast-check (flat) 0.1ms 0.0ms ~8x —

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 a Validation::fake() recorder for consumer testing.
  • Introduced RuleRegistrar for rule discovery/registration and wired new laranail::validation.* console commands.
  • Added Regex builder and expanded fluent string-rule regex APIs; removed legacy Laravel-11 compatibility branches and the getEachRules() 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
imanimanyara merged commit f1db78d into main Aug 24, 2026
1 check passed
@imanimanyara
imanimanyara deleted the phase-1-extension-platform branch August 24, 2026 01:35
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.
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.

2 participants