Plan: Aura Filter Interface v5 — Stateless Design + Nested Field Support
Context
aura/filter, aura/input, and aura/html were all released at 2.x and have evolved
independently. aura/filter-interface was added later as a bridge (never released,
still at 3.0.0-alpha1). The stage-2 branch of aura/filter fixed the shallow-cast
bug that prevented multi-dimensional (nested) array filtering via recursive
arrayToObject()/objectToArray() in SubSpec. This plan ships:
- A clean v5 of
aura/filter-interface (no BC burden — never released)
aura/filter v5 incorporating the stage-2 nested array fix + stateless apply()
aura/input v5 adapting to the new contracts + dot-notation failure keys
aura/html — no changes needed (name rendering already works correctly)
Dependency Order
aura/filter-interface → aura/filter → aura/input
Complete each package before moving to the next.
Package 1: aura/filter-interface (v5.0.0)
No backward-compatibility concerns. Redesign freely.
New files
src/FailuresInterface.php — read-only consumer interface:
interface FailuresInterface {
public function isEmpty(): bool;
public function forField(string $field): array; // FailureInterface[]
public function forPath(string $path): array; // dot-notation "address.city"
public function getMessages(): array; // ['field' => ['msg', ...]]
public function getNestedMessages(): array; // ['address' => ['city' => [...]]]
}
src/FilterResultInterface.php — value object returned by apply():
interface FilterResultInterface {
public function isSuccess(): bool;
public function getValues(): array|object; // matches input type
public function getFailures(): FailuresInterface;
}
src/FilterResult.php — shared concrete value object, final:
final class FilterResult implements FilterResultInterface {
public function __construct(
private readonly bool $success,
private readonly array|object $values,
private readonly FailuresInterface $failures,
) {}
public function isSuccess(): bool { return $this->success; }
public function getValues(): array|object { return $this->values; }
public function getFailures(): FailuresInterface { return $this->failures; }
}
src/Failure.php — shared concrete Failure value object, final:
final class Failure implements FailureInterface {
public function __construct(
private readonly string $field,
private readonly string $message,
private readonly array $args = [],
) {}
public function getField(): string { return $this->field; }
public function getMessage(): string { return $this->message; }
public function getArgs(): array { return $this->args; }
public function jsonSerialize(): array {
return ['field' => $this->field, 'message' => $this->message, 'args' => $this->args];
}
}
src/FailureCollection.php — shared concrete collection, final:
Implements FailureCollectionInterface (which extends FailuresInterface). Uses a plain
private array $items = [] (keyed by field → Failure[]), replacing the ArrayObject
inheritance in aura/filter and the string-based storage in aura/input.
Methods required by the interfaces:
isEmpty(): bool
add(string $field, string $message, array $args = []): FailureInterface — creates Failure, appends
set(string $field, string $message, array $args = []): FailureInterface — replaces all for field
forField(string $field): array — returns Failure[] for the field
forPath(string $path): array — looks up flat dot-notation key (e.g. "address.city"); same as forField($path) since the flat storage already uses dot-keys for nested failures
getMessages(): array — returns ['field' => ['msg1', ...], ...]
getNestedMessages(): array — splits keys on . and builds a nested array
Extra concrete helper (not on any interface, used by aura/input closures and Fieldset):
addMessagesForField(string $field, string|array $messages): void — loops and calls add()
Rationale: FilterResult, Failure, and FailureCollection are all final typed containers
with no extension points. Shipping them here eliminates identical duplication across both
implementing packages. Both aura/filter and aura/input drop their own concrete classes
and reference Aura\Filter_Interface\{Failure, FailureCollection, FilterResult} directly.
Modified files
src/FailureCollectionInterface.php
- Add
extends FailuresInterface — implementations satisfy both contracts automatically
- Keep only write methods:
add(), set()
- Remove
getMessagesForField() (moved to concrete implementations as non-interface method)
isEmpty(), getMessages(), forField(), forPath(), getNestedMessages() are inherited from FailuresInterface
src/FilterInterface.php
- Change signature:
apply(array|object $values): FilterResultInterface
- Remove
& — no silent mutation
- Remove
getFailures() — it moves onto the result
src/SubjectFilterInterface.php — no change; inherits the new apply() from FilterInterface
src/FailureInterface.php — no change
composer.json — "php": "^8.1"
Package 2: aura/filter (v5.0.0)
Incorporates stage-2 nested array fix into the new stateless design.
Both new FilterResult(...) calls use Aura\Filter_Interface\FilterResult directly.
Modified files
src/SubjectFilter.php — the central refactor
apply(array|object $values): FilterResultInterface
- Remove
& from parameter
- For arrays: pass value (no ref), delegate to
applyToArray()
- For objects:
clone $values, delegate to applyToObject()
- Return
FilterResult value object
- Remove
$this->failures and $this->skip as instance state — they become a local
context array $ctx = ['failures' => clone $this->proto_failures, 'skip' => []]
threaded through protected helpers via &$ctx
Protected helpers — add array &$ctx parameter to:
applyToArray(array $array): FilterResult — no &, returns FilterResult wrapping (array)$object
applyToObject(object $working): FilterResult — clones subject, creates $ctx, iterates specs
applySpec(Spec $spec, object $subject, array &$ctx): bool
failed(Spec $spec, object $subject, array &$ctx): FailureInterface
In failed(), the SubSpec failure propagation (stage-2) uses $spec->getLastResult()->getFailures() instead of $spec->filter()->getFailures().
assert(array|object $subject): void
- Remove
&; call $this->apply($subject); throw FilterFailed on failure with $result->getFailures()
__invoke(array|object &$subject)
- Keep
& here only — intentional mutation for assertion-callable use
- Calls
apply(), writes $result->getValues() back into $subject on success, throws on failure
Remove getFailures(): FailureCollection — no longer on FilterInterface; delete the method.
src/Spec/SubSpec.php — adapt to stateless sub-filter
Add private ?FilterResultInterface $lastResult = null
__invoke($subject): bool
$values = $subject->$field; // no & — write-back done explicitly
if (is_array($values)) {
$obj = $this->arrayToObject($values); // stage-2: recursive deep conversion
$this->lastResult = $this->filter->apply($obj);
$subject->$field = $this->objectToArray($this->lastResult->getValues());
} else {
$this->lastResult = $this->filter->apply($values);
$subject->$field = $this->lastResult->getValues();
}
return $this->lastResult->isSuccess();
The $subject is the parent's working copy (stdClass handle) — assignment writes through.
getDefaultMessage() — change from $this->filter->getFailures()->getMessages()
to $this->lastResult?->getFailures()->getMessages() ?? []
Keep arrayToObject() and objectToArray() from stage-2 unchanged (only converts instanceof \stdClass back, preserving DTOs).
filter(): SubjectFilterInterface — unchanged (returns the sub-filter for fluent chaining).
src/Failure/FailureCollection.php — delete; replaced by shared Aura\Filter_Interface\FailureCollection
src/Failure/Failure.php — delete; replaced by shared Aura\Filter_Interface\Failure
All internal usages in SubjectFilter, SubSpec, FilterFailed, etc. change their import
from Aura\Filter\Failure\{Failure,FailureCollection} to Aura\Filter_Interface\{Failure,FailureCollection}.
Note: SubjectFilter::applyToObject() previously accessed FailureCollection via ArrayObject
index syntax ($this->failures[$field]). With the plain-class replacement this becomes method
calls ($this->failures->forField($field) for reads, $this->failures->add() for writes).
src/Exception/FilterFailed.php
setFailures(FailuresInterface $failures) / getFailures(): FailuresInterface
- Remove concrete
FailureCollection import
src/FilterFactory.php
newSubjectFilter(): SubjectFilterInterface (was object)
composer.json — "php": "^8.1", "aura/filter-interface": "^5.0"
Test updates
tests/SubjectFilterTest.php:
$result = $filter->apply($data) → assertions on $result->isSuccess() and $result->getFailures()
- Tests that asserted
$array was mutated after apply() → assert $result->getValues() instead
- Subfilter tests (
testSubfilter_nestedArray*) carry over from stage-2 unchanged in intent
tests/Failure/FailureCollectionTest.php:
- Add tests for
forPath() and getNestedMessages()
Package 3: aura/input (v5.0.0)
new FilterResult(...) calls use Aura\Filter_Interface\FilterResult (from the interface package).
Modified files
src/Filter.php — minimal, pragmatic update
The closure contract is function ($value, &$fields) where $fields is the Fieldset object.
Closures that sanitize write back via $fields->fieldname = ... (object handle). No Fieldset
clone is needed or practical — the Fieldset IS the subject.
public function apply(array|object $values): FilterResultInterface
{
$this->failures = clone $this->proto_failures; // reset; kept for mid-run access
foreach ($this->rules as $field => $rules) {
foreach ($rules as [$message, $closure]) {
if (! $closure($values->$field, $values)) {
$this->failures->addMessagesForField($field, $message);
}
}
}
return new \Aura\Filter_Interface\FilterResult($this->failures->isEmpty(), $values, $this->failures);
}
Key points:
& removed from parameter (object already passed by handle)
$this->failures kept as private live state so closures calling $filter->getFailures()->addMessagesForField() mid-run continue to work
getFailures(): FailureCollectionInterface stays as a concrete non-interface method (not required by FilterInterface) for this backward-compatible pattern
getMessages() and addMessages() stay as concrete helpers
src/Filter/FailureCollection.php — delete; replaced by shared Aura\Filter_Interface\FailureCollection
src/Filter/Failure.php (if present) — delete; replaced by shared Aura\Filter_Interface\Failure
Usages in Filter, Fieldset, Collection change imports to Aura\Filter_Interface\FailureCollection.
The addMessagesForField() concrete method is available on the shared class so existing
call sites are unchanged.
src/Fieldset.php — the central change
Replace filter() body (currently lines 359–379):
public function filter(): bool
{
$collector = new \Aura\Filter_Interface\FailureCollection();
$result = $this->filter->apply($this);
$this->success = $result->isSuccess();
foreach ($result->getFailures()->getMessages() as $field => $messages) {
$collector->addMessagesForField($field, $messages);
}
foreach ($this->inputs as $name => $input) {
if ($input instanceof Fieldset || $input instanceof Collection) {
if (! $input->filter()) {
$this->success = false;
foreach ($input->getFailures()->getMessages() as $field => $msgs) {
$collector->addMessagesForField("{$name}.{$field}", $msgs);
}
}
}
}
$this->failures = $collector;
return $this->success;
}
Changes:
instanceof FailureCollectionInterface check gone — getFailures() on both Fieldset and Collection always returns FailuresInterface
- Nested failures use dot-notation:
phone_numbers.0.number instead of phone_numbers → nested array
getFailures() return type annotation: FailuresInterface
src/Collection.php
public function getFailures(): FailuresInterface
{
$collector = new \Aura\Filter_Interface\FailureCollection(); // shared class
foreach ($this->fieldsets as $key => $fieldset) {
foreach ($fieldset->getFailures()->getMessages() as $field => $messages) {
$collector->addMessagesForField("{$key}.{$field}", $messages);
}
}
return $collector;
}
composer.json — "php": "^8.1", "aura/filter-interface": "^5.0"
Test updates
tests/FilterTest.php:
$result = $filter->apply($values); $result->isSuccess() instead of $passed = $filter->apply()
- Sanitize assertions:
$result->getValues()->$field instead of asserting $values was mutated
testMultipleErrorMessages: works unchanged (closures access $filter->getFailures() which is the live $this->failures)
tests/CollectionTest.php:
$collection->getFailures() now returns FailuresInterface not array
- Assertion on
$actual[1] (array offset) must change to $actual->getMessages()['1.foo']
tests/FormTest.php:
- Nested failure access changes from
$failures->getMessagesForField('phone_numbers')[2]['number'][0]
to $failures->getMessages()['phone_numbers.2.number'][0]
or $failures->forPath('phone_numbers.2.number')[0]->getMessage()
Package 4: aura/html
No changes required. Field names like address[city] are already rendered correctly
(the name is passed through from Aura.Input hints as-is). The Select [] suffix logic
in src/Helper/Input/Select.php already handles the multiple case correctly.
Verification
Per-package
# aura/filter-interface — no tests; verify via implementing packages
composer install && php -l src/**/*.php
# aura/filter
composer install && ./vendor/bin/phpunit --no-coverage
# aura/input
composer install && ./vendor/bin/phpunit --no-coverage
Integration smoke test (after both packages installed)
Use the tests/Example/ suite in aura/input:
./vendor/bin/phpunit tests/Example/ExampleTest.php
This test exercises a nested Contact form (with AddressFieldset, phone Collection, and a
bundled Filter) end-to-end. It must pass unchanged in all assertions except failure key
format (dot-notation change is expected and the test should be updated to match).
Stage-2 specific
Run the subfilter tests carried from stage-2 in aura/filter:
testSubfilter_nestedArrayValidationFails
testSubfilter_nestedArraySanitizeWritesBack
testSubfilter_deeplyNestedArray
testSubfilter_plainArrayFieldUnaffectedBySubfilter
All must pass with the stateless apply() — confirming that SubSpec's write-back to the
parent working copy still propagates sanitized nested values correctly.
Plan: Aura Filter Interface v5 — Stateless Design + Nested Field Support
Context
aura/filter,aura/input, andaura/htmlwere all released at 2.x and have evolvedindependently.
aura/filter-interfacewas added later as a bridge (never released,still at 3.0.0-alpha1). The
stage-2branch ofaura/filterfixed the shallow-castbug that prevented multi-dimensional (nested) array filtering via recursive
arrayToObject()/objectToArray()inSubSpec. This plan ships:aura/filter-interface(no BC burden — never released)aura/filterv5 incorporating the stage-2 nested array fix + statelessapply()aura/inputv5 adapting to the new contracts + dot-notation failure keysaura/html— no changes needed (name rendering already works correctly)Dependency Order
Complete each package before moving to the next.
Package 1:
aura/filter-interface(v5.0.0)No backward-compatibility concerns. Redesign freely.
New files
src/FailuresInterface.php— read-only consumer interface:src/FilterResultInterface.php— value object returned byapply():src/FilterResult.php— shared concrete value object,final:src/Failure.php— shared concreteFailurevalue object,final:src/FailureCollection.php— shared concrete collection,final:Implements
FailureCollectionInterface(which extendsFailuresInterface). Uses a plainprivate array $items = [](keyed by field →Failure[]), replacing theArrayObjectinheritance in
aura/filterand the string-based storage inaura/input.Methods required by the interfaces:
isEmpty(): booladd(string $field, string $message, array $args = []): FailureInterface— createsFailure, appendsset(string $field, string $message, array $args = []): FailureInterface— replaces all for fieldforField(string $field): array— returnsFailure[]for the fieldforPath(string $path): array— looks up flat dot-notation key (e.g."address.city"); same asforField($path)since the flat storage already uses dot-keys for nested failuresgetMessages(): array— returns['field' => ['msg1', ...], ...]getNestedMessages(): array— splits keys on.and builds a nested arrayExtra concrete helper (not on any interface, used by
aura/inputclosures andFieldset):addMessagesForField(string $field, string|array $messages): void— loops and callsadd()Rationale:
FilterResult,Failure, andFailureCollectionare allfinaltyped containerswith no extension points. Shipping them here eliminates identical duplication across both
implementing packages. Both
aura/filterandaura/inputdrop their own concrete classesand reference
Aura\Filter_Interface\{Failure, FailureCollection, FilterResult}directly.Modified files
src/FailureCollectionInterface.phpextends FailuresInterface— implementations satisfy both contracts automaticallyadd(),set()getMessagesForField()(moved to concrete implementations as non-interface method)isEmpty(),getMessages(),forField(),forPath(),getNestedMessages()are inherited fromFailuresInterfacesrc/FilterInterface.phpapply(array|object $values): FilterResultInterface&— no silent mutationgetFailures()— it moves onto the resultsrc/SubjectFilterInterface.php— no change; inherits the newapply()fromFilterInterfacesrc/FailureInterface.php— no changecomposer.json—"php": "^8.1"Package 2:
aura/filter(v5.0.0)Incorporates stage-2 nested array fix into the new stateless design.
Both
new FilterResult(...)calls useAura\Filter_Interface\FilterResultdirectly.Modified files
src/SubjectFilter.php— the central refactorapply(array|object $values): FilterResultInterface&from parameterapplyToArray()clone $values, delegate toapplyToObject()FilterResultvalue object$this->failuresand$this->skipas instance state — they become a localcontext array
$ctx = ['failures' => clone $this->proto_failures, 'skip' => []]threaded through protected helpers via
&$ctxProtected helpers — add
array &$ctxparameter to:applyToArray(array $array): FilterResult— no&, returns FilterResult wrapping(array)$objectapplyToObject(object $working): FilterResult— clones subject, creates$ctx, iterates specsapplySpec(Spec $spec, object $subject, array &$ctx): boolfailed(Spec $spec, object $subject, array &$ctx): FailureInterfaceIn
failed(), theSubSpecfailure propagation (stage-2) uses$spec->getLastResult()->getFailures()instead of$spec->filter()->getFailures().assert(array|object $subject): void&; call$this->apply($subject); throwFilterFailedon failure with$result->getFailures()__invoke(array|object &$subject)&here only — intentional mutation for assertion-callable useapply(), writes$result->getValues()back into$subjecton success, throws on failureRemove
getFailures(): FailureCollection— no longer onFilterInterface; delete the method.src/Spec/SubSpec.php— adapt to stateless sub-filterAdd
private ?FilterResultInterface $lastResult = null__invoke($subject): boolThe
$subjectis the parent's working copy (stdClass handle) — assignment writes through.getDefaultMessage()— change from$this->filter->getFailures()->getMessages()to
$this->lastResult?->getFailures()->getMessages() ?? []Keep
arrayToObject()andobjectToArray()from stage-2 unchanged (only convertsinstanceof \stdClassback, preserving DTOs).filter(): SubjectFilterInterface— unchanged (returns the sub-filter for fluent chaining).src/Failure/FailureCollection.php— delete; replaced by sharedAura\Filter_Interface\FailureCollectionsrc/Failure/Failure.php— delete; replaced by sharedAura\Filter_Interface\FailureAll internal usages in
SubjectFilter,SubSpec,FilterFailed, etc. change their importfrom
Aura\Filter\Failure\{Failure,FailureCollection}toAura\Filter_Interface\{Failure,FailureCollection}.Note:
SubjectFilter::applyToObject()previously accessedFailureCollectionvia ArrayObjectindex syntax (
$this->failures[$field]). With the plain-class replacement this becomes methodcalls (
$this->failures->forField($field)for reads,$this->failures->add()for writes).src/Exception/FilterFailed.phpsetFailures(FailuresInterface $failures)/getFailures(): FailuresInterfaceFailureCollectionimportsrc/FilterFactory.phpnewSubjectFilter(): SubjectFilterInterface(wasobject)composer.json—"php": "^8.1","aura/filter-interface": "^5.0"Test updates
tests/SubjectFilterTest.php:$result = $filter->apply($data)→ assertions on$result->isSuccess()and$result->getFailures()$arraywas mutated afterapply()→ assert$result->getValues()insteadtestSubfilter_nestedArray*) carry over from stage-2 unchanged in intenttests/Failure/FailureCollectionTest.php:forPath()andgetNestedMessages()Package 3:
aura/input(v5.0.0)new FilterResult(...)calls useAura\Filter_Interface\FilterResult(from the interface package).Modified files
src/Filter.php— minimal, pragmatic updateThe closure contract is
function ($value, &$fields)where$fieldsis the Fieldset object.Closures that sanitize write back via
$fields->fieldname = ...(object handle). No Fieldsetclone is needed or practical — the Fieldset IS the subject.
Key points:
&removed from parameter (object already passed by handle)$this->failureskept as private live state so closures calling$filter->getFailures()->addMessagesForField()mid-run continue to workgetFailures(): FailureCollectionInterfacestays as a concrete non-interface method (not required byFilterInterface) for this backward-compatible patterngetMessages()andaddMessages()stay as concrete helperssrc/Filter/FailureCollection.php— delete; replaced by sharedAura\Filter_Interface\FailureCollectionsrc/Filter/Failure.php(if present) — delete; replaced by sharedAura\Filter_Interface\FailureUsages in
Filter,Fieldset,Collectionchange imports toAura\Filter_Interface\FailureCollection.The
addMessagesForField()concrete method is available on the shared class so existingcall sites are unchanged.
src/Fieldset.php— the central changeReplace
filter()body (currently lines 359–379):Changes:
instanceof FailureCollectionInterfacecheck gone —getFailures()on bothFieldsetandCollectionalways returnsFailuresInterfacephone_numbers.0.numberinstead ofphone_numbers→ nested arraygetFailures()return type annotation:FailuresInterfacesrc/Collection.phpcomposer.json—"php": "^8.1","aura/filter-interface": "^5.0"Test updates
tests/FilterTest.php:$result = $filter->apply($values); $result->isSuccess()instead of$passed = $filter->apply()$result->getValues()->$fieldinstead of asserting$valueswas mutatedtestMultipleErrorMessages: works unchanged (closures access$filter->getFailures()which is the live$this->failures)tests/CollectionTest.php:$collection->getFailures()now returnsFailuresInterfacenotarray$actual[1](array offset) must change to$actual->getMessages()['1.foo']tests/FormTest.php:$failures->getMessagesForField('phone_numbers')[2]['number'][0]to
$failures->getMessages()['phone_numbers.2.number'][0]or
$failures->forPath('phone_numbers.2.number')[0]->getMessage()Package 4:
aura/htmlNo changes required. Field names like
address[city]are already rendered correctly(the name is passed through from Aura.Input hints as-is). The Select
[]suffix logicin
src/Helper/Input/Select.phpalready handles themultiplecase correctly.Verification
Per-package
Integration smoke test (after both packages installed)
Use the
tests/Example/suite inaura/input:This test exercises a nested Contact form (with AddressFieldset, phone Collection, and a
bundled
Filter) end-to-end. It must pass unchanged in all assertions except failure keyformat (dot-notation change is expected and the test should be updated to match).
Stage-2 specific
Run the subfilter tests carried from stage-2 in
aura/filter:testSubfilter_nestedArrayValidationFailstestSubfilter_nestedArraySanitizeWritesBacktestSubfilter_deeplyNestedArraytestSubfilter_plainArrayFieldUnaffectedBySubfilterAll must pass with the stateless
apply()— confirming thatSubSpec's write-back to theparent working copy still propagates sanitized nested values correctly.