Skip to content

Fix four silent validation failures, add PHP 8.5 support and an optional Laravel provider - #7

Merged
yordadev merged 25 commits into
mainfrom
feature/v1.1.0
Sep 10, 2026
Merged

Fix four silent validation failures, add PHP 8.5 support and an optional Laravel provider#7
yordadev merged 25 commits into
mainfrom
feature/v1.1.0

Conversation

@yordadev

@yordadev yordadev commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

Prepares v1.1.0. Adds PHP 8.5 and Laravel 12/13 to the supported range, ships the optional Laravel integration the package has advertised since v1.0 but never actually contained, and fixes four defects that let the validator return a plausible answer without doing the work.

No public API is removed, and composer.lock still resolves zero runtime packages — the core library stays framework-agnostic.

Four silent failures

Same shape in all four: a value that is almost right flows through a type juggle or an uninitialised read, and the caller gets a confident answer instead of an error.

passes() and fails() answered before validating. Both read $this->errors directly, which is [] on a fresh instance. A caller who skipped validate() got passes() === true and fails() === false on data that had never been looked at:

$v = Validator::make(['email' => 'not-an-email'], ['email' => 'required|email']);
$v->fails();   // false, on main

Both now route through validationPassed(), which validates on first use and memoises with a hasValidated flag. The flag is set on return, not on entry, so a rule that throws leaves the validator reporting unvalidated rather than passed, and it is included in __serialize()/__unserialize() so a round-tripped validator does not silently re-run.

Lazy validation introduces a re-entrancy hazard that did not exist before: a rule holding a reference to its own validator and calling passes() mid-run would re-enter validate()hasValidated is still false — and recurse until the stack exhausted. A transient $validating flag, reset in a finally, makes such a call report the errors accumulated so far instead. It is deliberately excluded from serialization.

The parsed-rules cache never evicted at small limits. Eviction sliced with -self::$cacheLimit / 2, a float, which array_slice() truncates toward zero:

parsedRulesCache -$limit / 2 offset used entries retained
500 -250.0 -250 250
5 -2.5 -2 2
1 -0.5 0 all — never evicts

At a limit of 1 the offset collapses to 0, array_slice() returns the whole array, and the cache grows without bound for the life of the process. Now max(1, intdiv($limit, 2)). The regression test uses limit 1 specifically — an earlier draft used 5, which passes against both implementations and would have proved nothing.

The dynamic chunking heuristic was reachable only through a PHP warning. determineChunkSize() guarded on ($chunkSize = $this->config->chunkSize) !== null, but $config is ?DataValidationConfig and defaults to null. With no config, that read emits Attempt to read property "chunkSize" on null, evaluates to null, and falls through to the heuristic — so the default path for every wildcard validation ran on a diagnostic. With a config, chunkSize was a non-nullable int initialised to 500, so the guard never passed and the heuristic was unreachable. Now $this->config?->chunkSize, with chunkSize widened to ?int so null is a supported way to opt into the heuristic, and a max(1, …) floor so a configured 0 cannot reach array_chunk() and throw a ValueError.

optimizeForLargeDataset() assigned floats to three int properties. $totalItems / 2, / 20 and / 100 produce floats for every non-divisible total, deprecated as implicit lossy conversion. intdiv() throughout.

Optional Laravel integration

extra.laravel.providers on main names YorCreative\DataValidation\DataValidationServiceProvider. No such class exists in the package. Auto-discovery registers whatever the manifest lists, so installing v1.0 into a Laravel application fails at package:discover with a class-not-found — the integration was declared, never shipped.

src/Laravel/DataValidationServiceProvider.php is that class, under a Laravel\ sub-namespace so its intent is visible from the path. It binds:

  • DataValidationConfig as a singleton hydrated from config/data-validation.php, keyed by array_key_exists() so a published config may set chunk_size to null and get the heuristic
  • yorcreative.data-validation, a factory closure returning a Validator already carrying that config
php artisan vendor:publish --tag=data-validation-config

It is the only file in src/ referencing Illuminate\*, and it is only autoloaded inside a Laravel app. illuminate/support is declared under suggest, not requirecomposer.lock still shows "packages": [].

Validator::isValid() also now takes a ?DataValidationConfig. The constructor and make() both accepted one; the one-line static entry point dropped it on the floor, so the provider had no way to thread configuration through the shortest call.

Platform and CI matrix

  • PHP ^8.3 || ^8.4 || ^8.5; the CI matrix gains 8.5 and the Dockerfile takes ARG PHP_VERSION so all three legs build from one file
  • Dev dependencies move to Laravel 10–13 via direct illuminate/* components. orchestra/testbench is removed — it pins a Laravel range of its own, which is the thing standing between this package and a 10–13 matrix. LaravelServiceProviderTest wires a bare Illuminate\Container\Container plus a config repository directly, which is all the provider actually needs
  • actions/checkout@v3@v4, and --no-suggest--no-interaction (Composer 2.10 reports --no-suggest as "deprecated… has no effect and will break in Composer 3")

config.platform.php is pinned to 8.3.0. Without it the lock resolves against whichever PHP generated it: twelve Symfony dev packages (clock, console, event-dispatcher, filesystem, finder, http-foundation, mime, options-resolver, process, stopwatch, string, translation) landed on 8.x releases requiring PHP >=8.4, making the lockfile uninstallable on 8.3composer install exits 2 and the 8.3 leg would have failed on merge. This was caught by running the matrix locally before opening the PR, not by CI. The pin moves those twelve to 7.4.x, and one lockfile now installs across the whole supported range.

Diagnostics are now failures

phpunit.xml sets failOnDeprecation="true" and failOnWarning="true". This is not hygiene — it is load-bearing. Two of the fixes above (the intdiv() conversions and the null-config read) have no value-level observable: behaviour is identical, only the diagnostic differs. Without these flags their tests pass against the unfixed code and guard nothing.

The ordering that made this possible is worth noting for anyone bisecting: applyRules() declared array &$data while never writing to it, and two reflection-based test fixtures raised Argument #5 ($data) must be passed by reference as a result. Dropping the & took the warning count to zero, which is what let failOnWarning be switched on without manufacturing failures — so the signature change lands before the tests that depend on it.

Verification

Full matrix run locally in Docker, each leg building its own image from the same Dockerfile:

PHP composer install PHPUnit Result
8.3.33 exit 0 exit 0 483 tests, 598 assertions
8.4.25 exit 0 exit 0 483 tests, 598 assertions
8.5.10 exit 0 exit 0 483 tests, 598 assertions

3/3 green, zero warnings and zero deprecations under the flags above. phpcs --standard=PSR12 ./src exits 0. composer.lock reports 0 runtime packages, 95 dev.

461 test methods on main → 483 here. Every substantive fix was watched failing for the right reason before the fix landed, with exit codes captured rather than banner text — failOnDeprecation only changes the shell exit code, and PHPUnit still prints an OK-style summary alongside it, so stdout is a poor witness.

Five tests were flagged during final review as unable to discriminate v1.0 from v1.1. One was a genuine gap — the serialization round-trip test passed with the hasValidated line deleted — and now guards both __serialize() and __unserialize(). One (testValidatorIsValidSupportsConfigObject) is inert for config threading because PHP discards a surplus argument; rather than delete it, its name and docblock have been narrowed to the null-chunkSize smoke test it actually is. The rest are deliberate forward guards for later workstreams.

Known issue, not addressed here

self::$cacheLimit is process-global, and passing a config writes to it from the constructor. Workstream 0 widens its reach three ways at once: isValid() now accepts a config, the provider routes every application validation through a config-bearing make(), and a new test enshrines the write as the specified observable.

In a long-lived worker (Octane, queues) a job that calls optimizeForLargeDataset(100000) leaves the static at 5000; the next job in that process inherits a 5000-entry parsed-rules cache it never asked for, and nothing writes 500 back. Pre-existing v1.0 shape, and Workstream 2.1 removes the static outright — flagged here so the two new callers are on record as needing to be re-pointed at instance state.

Also

  • fails()/passes() lazy behaviour, the Laravel section and the 8.5 requirement are documented in the README
  • Long lines in src/ wrapped to PSR-12's 120-character limit; a stray trailing-space keyword in composer.json corrected
  • The docs/superpowers/ additions are the design spec, execution plan and review record for this workstream. Process artefacts with no runtime effect — they account for most of the line count outside composer.lock

Reverts src/Validator.php and src/DataValidationConfig.php to v1.0.0 and
removes the service provider, so the eleven kept tests become a genuine
failing baseline rather than tests that were never watched failing.

Rewrites testPassesTriggersValidationOnFirstUse, which was inert: it
asserted passes() === true on valid data, which holds for both the v1.0
and the fixed implementation. It now asserts against invalid data, where
only a lazily-validating passes() can return false.

Fixes indentation on RuleRegistryTest::testDeriveRuleNameFromClassName.
PHP 8.5 support, Laravel 10-13 dev matrix (orchestra/testbench removed),
Docker and CI updates, and the publishable package config. Declarative
configuration, kept under the TDD skill's configuration exception.
The optimizeForLargeDataset fix has no observable value difference:
truncating a positive float equals intdiv() of the same operands, so no
assertion on the resulting values can distinguish the implementations.
failOnDeprecation makes the deprecation itself the failure signal, which
is the only thing that actually changes.

Renames the small-totals test, which guards max() clamping rather than
integer division, to say what it tests.
Warnings reached zero once applyRules stopped declaring $data by
reference. Enabling failOnWarning makes a PHP warning a failure signal,
which is what several null-safety behaviours in this class actually
produce -- they have no observable value difference to assert on.
A fresh validator previously reported passes() === true because it read an
empty errors array before validation had run. The hasValidated flag makes
the first call trigger validation, and is carried through serialization so
a restored validator does not silently re-validate.
Binds a DataValidationConfig singleton hydrated from config/data-validation.php
and a yorcreative.data-validation factory closure. The core library keeps zero
runtime framework dependencies; this file is only autoloaded inside Laravel.
vendor/bin/phpcs --standard=PSR12 ./src reported 0 errors but 21 line-
length warnings across RuleRegistry.php, Validator.php, and 12 rule
classes. Task 11's original brief named php-cs-fixer, but this project
has no php-cs-fixer config and never has; the actual style tool is
PHP_CodeSniffer (phpcbf --standard=PSR12), matching composer.json's
"lint" script and .github/workflows/fix-php-code-style-issues.yml.

phpcbf could not auto-fix these (line-length is not auto-fixable), so
each offending line was manually wrapped without changing behavior.
vendor/bin/phpcs --standard=PSR12 ./src now reports 0 errors, 0
warnings. Full suite re-confirmed green: OK (480 tests, 587 assertions).
composer.lock was resolved on PHP 8.5 and pinned twelve symfony dev
packages requiring PHP >=8.4, making `composer install` fail on the 8.3
CI leg despite composer.json advertising ^8.3 || ^8.4 || ^8.5.

Pinning config.platform.php to 8.3.0 resolves dev dependencies against
the oldest supported version, so one lock installs across the whole
matrix. Runtime dependencies remain zero.
validate() previously set hasValidated = true as its first statement.
If a rule threw mid-validation, the exception propagated but the flag
was already true and $this->errors was still empty, so a later
passes() (or fails()) would report success for a validation run that
never completed. hasValidated is now set immediately before each of
validate()'s two return points instead.
testValidationStateSurvivesSerializationRoundTrip previously asserted
only fails()/errors() after a round trip, both of which still pass
even if hasValidated is dropped from __serialize()/__unserialize()
entirely: an unserialized instance defaults hasValidated to false,
fails() silently re-runs validate(), and errors repopulate. Add a
reflection assertion on the restored instance's hasValidated to
actually guard those two lines.

Verified non-inert: with 'hasValidated' => $this->hasValidated
removed from __serialize(), the test failed (EXIT=1) with 'hasValidated
must be restored as true by the serialization round trip. Failed
asserting that false is true.' Restoring the line returns EXIT=0.
boot()'s runningInConsole()/publishes() wiring needs an application
object exposing runningInConsole() and configPath(); building one over
a bare Illuminate\Container\Container would be a test double, which
this project forbids. Cover instead the one failure mode that can
break silently: configPath() resolving to a file that does not exist,
which would make vendor:publish --tag=data-validation-config publish
nothing while appearing to succeed.

Also documents this coverage gap in a comment above boot().
Three tests are only meaningful because phpunit.xml sets
failOnDeprecation="true" / failOnWarning="true" -- the behaviours they
cover have no value-level observable other than a deprecation/warning
notice. Add a comment above each test naming the flag it depends on,
and a comment in phpunit.xml above the root element recording that
both flags are load-bearing and must not be removed without replacing
those tests.
Records that hasValidated is a claim about the CURRENT $data/$rules,
holding only because Validator has no mutators for either. Any future
setter must reset hasValidated = false or passes()/fails() will return
a verdict about data that no longer exists. Later workstreams add
validated() and validateOrFail() on top of this flag.
…Object

Despite its name, this test does not prove that isValid() threads the
config through to the Validator -- it passes even against code that
silently drops the config argument, since chunkSize = null is also the
Validator's default. Add a docblock narrowing it to the null-chunk-size
smoke path it actually covers, and cross-reference
testIsValidThreadsTheConfigThroughToTheValidator(), which is the test
that proves config threading. No rename, no assertion changes.
A rule that captures its own validator and calls passes() or fails()
mid-validation re-entered validate(), because hasValidated is not set
until validate() returns. That recursed until the stack exhausted.

A transient validating flag lets a re-entrant call report the state
accumulated so far instead of restarting. The flag resets in a finally
block so a throwing rule cannot leave it set, and it is deliberately
excluded from serialization as transient state.
@yordadev yordadev self-assigned this Sep 9, 2026
@yordadev yordadev changed the title Workstream 0: rebuild the v1.1 base under TDD Fix four silent validation failures, add PHP 8.5 support and an optional Laravel provider Sep 9, 2026
…tate

Two silent correctness defects in Validator::validate(), both pre-existing on
main rather than introduced by this branch.

The traversal queue held references to loop-local variables, so every queued
entry aliased the same value. Because the queue is FIFO, entries were consumed
after later iterations had already reassigned that variable, and each field was
validated against whichever sibling's value happened to be assigned last. The
visible symptom was order-dependent results; the dangerous one was invalid data
passing outright whenever the last sibling processed was valid. Wildcard rows
were affected the same way, and same/different/required_if compared the wrong
field once siblings were present. Nothing wrote through those references --
applyRules() takes its value by value -- so passing the value directly is both
correct and cheaper: taking references to array elements was breaking PHP's
copy-on-write, and peak memory over 20k rows drops from 46MB to 42MB.

Separately, hasValidated was never cleared when a run began. A completed run
followed by one that threw part-way left the flag set while $errors had already
been emptied, so passes() reported success for a validation that never
finished. It is now cleared as the run starts and set only where the run
completes, including the stopOnFirstError early failure.
The provider's boot() had no coverage: exercising it needs an application
exposing runningInConsole() and configPath(), which a bare container does not
provide. laravel/framework ^12 || ^13 is now a dev dependency, so boot() runs
against a genuine Illuminate\Foundation\Application. The constraint stops at
^12 deliberately -- every laravel/framework release in the ^10 and ^11 ranges
is withheld by Composer's security-advisory policy, and reaching them would
mean setting policy.advisories.ignore.

Those two majors are still exercised. The new CI matrix resolves each of
Laravel 10 through 13 from scratch, using the split illuminate/* packages for
10 and 11 where the advisories do not apply. They cannot supply a real
application, so the four boot() tests skip themselves there and run everywhere
else.

Also covers package discovery, publication of the packaged config, drift
between that file and the compiled defaults, and the promise that a standalone
install still pulls in no Laravel runtime packages. The runtime require block
is unchanged and stays PHP-only.
The matrix jobs install no coverage extension, and phpunit.xml sets
failOnWarning, so PHPUnit's "No code coverage driver available" warning was
enough to fail all eight jobs while every test in them passed.

Pass --no-coverage there, which is what the job actually wants: it exists to
answer whether the suite passes against each dependency set, and coverage is
already collected by the locked-deps jobs that do install xdebug.
@yordadev
yordadev merged commit 554647e into main Sep 10, 2026
13 checks passed
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