Skip to content

Consider redesigning ValidatingHydrator to never return non-valid objects and signal failure via an exception carrying the Result #35

@Chi-teck

Description

@Chi-teck

Proposed new feature or change

Summary

The current shape of Yiisoft\Hydrator\Validator\ValidatingHydrator and its ValidatedInputInterface / ValidatedInputTrait companions has a structural property that I would like to discuss: create() may return an object that did not pass validation, and supporting that behaviour forces several non-obvious constraints onto every DTO that uses it.

This issue proposes reshaping the contract so that a returned object is always valid, with validation failures signalled by an exception that carries the Result.

Problems with the current design

1. create() can return an object that is not safe to use

The contract of ValidatingHydrator::create() is "returns the object", regardless of whether validation succeeded. The validation outcome is exposed only via $dto->getValidationResult(), so callers must follow a specific call sequence — first check the result, then touch any property — or risk a fatal Error: Typed property ... must not be accessed before initialization.

This is a footgun by default: the type system says "here is your DTO", but the runtime contract says "do not look at it yet". Any forgotten check turns into a production fatal.

2. DTO doubles as a validation-result container

A DTO's single responsibility is data transfer. ValidatedInputInterface + ValidatedInputTrait give it a second responsibility — carrying the validation result — which couples two concerns that have different lifetimes (the DTO is long-lived data; the result is a one-shot outcome of a hydration call).

3. Hidden constraints on DTO design

Because the trait stores mutable state and the hydrator populates properties post-construction via reflection, the following natural choices for an immutable input DTO are silently forbidden:

  • The class cannot be readonly. Using the trait in a readonly class fatals at load time because ValidatedInputTrait::$validationResult is not readonly.
  • The constructor cannot have required parameters. Any missing key in $input throws WrongConstructorArgumentsCountException before validation runs, so the validator can never report the real problem (the missing field). Every property must have a default, which contradicts the usual "required by type" idiom.

These constraints are not visible at the interface/trait level — they surface as runtime fatals.

Proposed design

Make a returned object always valid; signal failure with an exception that carries the Result.

try {
    $dto = $hydrator->create(InputDto::class, $input);
    // $dto is guaranteed valid here.
    echo $dto->email;
} catch (ValidationFailedException $exception) {
    $result = $exception->getResult();
    // render errors, return 422, etc.
}

This design has several consequences worth highlighting:

  • The type system and the runtime contract agree: if you hold a $dto, it is valid.
  • ValidatedInputInterface / ValidatedInputTrait are no longer required. DTOs can be plain final readonly class value objects with required constructor parameters typed as the domain demands.
  • The validation result is no longer mixed into the DTO; it lives on the exception, where it belongs (one-shot, tied to a single hydration attempt).
  • Errors caused by hydration itself (missing required constructor args, type-cast failures) can be folded into the same Result and reported through the same exception, instead of throwing a different, unrelated exception type before validation has a chance to run.

Alternative implementation: result wrapper instead of exceptions

Using exceptions to signal validation failure is a defensible default — validation failure is, by definition, an exceptional outcome from the hydrator's point of view — but it is a stylistic choice, and not everyone agrees that exceptions are the right vehicle for "user input was wrong". For projects that prefer to keep validation outcomes out of the exception channel, the same guarantee ("the object is only handed to you if it is valid") can be offered through a result wrapper instead.

A method such as create(): ValidationOutcome could return a small wrapper holding either the valid object or the failed Result. This is the well-known Result<T, E> / Either shape used by Rust, Swift, Kotlin, std::expected in C++, and similar — failure is encoded in the return value, and the caller is forced to handle both branches before reaching the object.

For readers more familiar with Go, the same idea is the idiomatic (value, error) multi-return:

dto, err := hydrator.Create(InputDto, input)
if err != nil {
    // render errors, return 422, etc.
    return
}
// dto is guaranteed valid here.
fmt.Println(dto.Email)

The PHP equivalent is a single wrapper object instead of two return values, but the shape and the caller obligation are the same: you cannot reach the data without first looking at the outcome.

The two implementations (exception-based and wrapper-based) are equivalent in what they guarantee — the choice between them is about taste and house style, not about correctness. Either one fixes the underlying issue; what matters is that ValidatingHydrator should not hand out objects whose validity is unknown.

Why the current shape is hard to fix incrementally

The "object always valid" contract cannot be retrofitted on top of ValidatedInputTrait without either:

  • removing the trait's mutable property (which breaks the existing public API), or
  • changing create() to throw on failure (which is a behavioural break for every existing caller).

So this is worth discussing as a deliberate design change rather than a patch.

Environment

  • PHP 8.x
  • yiisoft/hydrator-validator (current release)
  • yiisoft/validator (current release)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions