Skip to content

RFC: Stage 2 — Stateless Filter Interface (v5) #164

Description

@harikt

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:

  1. A clean v5 of aura/filter-interface (no BC burden — never released)
  2. aura/filter v5 incorporating the stage-2 nested array fix + stateless apply()
  3. aura/input v5 adapting to the new contracts + dot-notation failure keys
  4. 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.phpdelete; replaced by shared Aura\Filter_Interface\FailureCollection

src/Failure/Failure.phpdelete; 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.phpdelete; 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions