Fix four silent validation failures, add PHP 8.5 support and an optional Laravel provider - #7
Merged
Conversation
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
force-pushed
the
feature/v1.1.0
branch
from
September 9, 2026 17:36
a05f394 to
18f3cec
Compare
…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.
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.
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.lockstill 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()andfails()answered before validating. Both read$this->errorsdirectly, which is[]on a fresh instance. A caller who skippedvalidate()gotpasses() === trueandfails() === falseon data that had never been looked at:Both now route through
validationPassed(), which validates on first use and memoises with ahasValidatedflag. 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-entervalidate()—hasValidatedis still false — and recurse until the stack exhausted. A transient$validatingflag, reset in afinally, 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, whicharray_slice()truncates toward zero:parsedRulesCache-$limit / 2At 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. Nowmax(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$configis?DataValidationConfigand defaults tonull. With no config, that read emitsAttempt to read property "chunkSize" on null, evaluates tonull, and falls through to the heuristic — so the default path for every wildcard validation ran on a diagnostic. With a config,chunkSizewas a non-nullableintinitialised to500, so the guard never passed and the heuristic was unreachable. Now$this->config?->chunkSize, withchunkSizewidened to?intsonullis a supported way to opt into the heuristic, and amax(1, …)floor so a configured0cannot reacharray_chunk()and throw aValueError.optimizeForLargeDataset()assigned floats to threeintproperties.$totalItems / 2,/ 20and/ 100produce floats for every non-divisible total, deprecated as implicit lossy conversion.intdiv()throughout.Optional Laravel integration
extra.laravel.providersonmainnamesYorCreative\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 atpackage:discoverwith a class-not-found — the integration was declared, never shipped.src/Laravel/DataValidationServiceProvider.phpis that class, under aLaravel\sub-namespace so its intent is visible from the path. It binds:DataValidationConfigas a singleton hydrated fromconfig/data-validation.php, keyed byarray_key_exists()so a published config may setchunk_sizetonulland get the heuristicyorcreative.data-validation, a factory closure returning aValidatoralready carrying that configIt is the only file in
src/referencingIlluminate\*, and it is only autoloaded inside a Laravel app.illuminate/supportis declared undersuggest, notrequire—composer.lockstill shows"packages": [].Validator::isValid()also now takes a?DataValidationConfig. The constructor andmake()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
^8.3 || ^8.4 || ^8.5; the CI matrix gains 8.5 and the Dockerfile takesARG PHP_VERSIONso all three legs build from one fileilluminate/*components.orchestra/testbenchis removed — it pins a Laravel range of its own, which is the thing standing between this package and a 10–13 matrix.LaravelServiceProviderTestwires a bareIlluminate\Container\Containerplus a config repository directly, which is all the provider actually needsactions/checkout@v3→@v4, and--no-suggest→--no-interaction(Composer 2.10 reports--no-suggestas "deprecated… has no effect and will break in Composer 3")config.platform.phpis pinned to8.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.3 —composer installexits 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.xmlsetsfailOnDeprecation="true"andfailOnWarning="true". This is not hygiene — it is load-bearing. Two of the fixes above (theintdiv()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()declaredarray &$datawhile never writing to it, and two reflection-based test fixtures raisedArgument #5 ($data) must be passed by referenceas a result. Dropping the&took the warning count to zero, which is what letfailOnWarningbe 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:composer install3/3 green, zero warnings and zero deprecations under the flags above.
phpcs --standard=PSR12 ./srcexits 0.composer.lockreports 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 —failOnDeprecationonly 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
hasValidatedline 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-chunkSizesmoke test it actually is. The rest are deliberate forward guards for later workstreams.Known issue, not addressed here
self::$cacheLimitis 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-bearingmake(), 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 READMEsrc/wrapped to PSR-12's 120-character limit; a stray trailing-space keyword incomposer.jsoncorrecteddocs/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 outsidecomposer.lock