NOTE: If you're new to Value Objects, I recommend starting with the first article to understand the fundamentals.
Table of Contents
Introduction
In my previous articles, I've explored Value Objects from basic implementation to advanced patterns, entities, and building custom type systems. Each article built upon the previous, showing how PHP 8's features enable more elegant solutions.
But now, with PHP 8.5, we have a new and powerful ally that truly changes the game: the pipe operator (|>).
This operator opens up new possibilities for functional programming in PHP. It lets us express validation logic in a way that's fundamentally different from what we could do before.
Different and better. But still, I want to keep some consistency in the way the value object should be used.
I'm not a huge fan of functional programming, and I don't want to write all my code in a functional style because I think it can become very cryptic and difficult to maintain. But sometimes, being functional can solve problems in a very elegant way that would be impossible in an OOP approach.
I know PHP programmers aren't usually familiar with functional jargon like functors and monads, so I’ll try not to go too deep into theoretical details and instead keep things at a high level, focusing on a pragmatic approach. Occasionally, though, for the sake of completeness, I’ll mention some of these terms.
I'll break down the approach step by step, showing each building block that enables this validation paradigm.
If you want to play around with this without implementing it yourself, here is a GitHub repository.
The Problem with Traditional Validation
In my previous articles, I explained how Value Objects encapsulate validation rules directly in the constructor. For example:
readonly final class Age
{
public function __construct(public int $value)
{
($value < 0) or throw new InvalidArgumentException("Age cannot be negative");
($value > 150) or throw new InvalidArgumentException("Age cannot exceed 150");
}
}
While this approach gets the job done, it does have several drawbacks:
- Only the first error is reported. As soon as one validation fails, an exception is thrown and further checks are skipped. If there are multiple issues, the user will only see the first one.
- Imperative, step-by-step style. The validation logic is written as a series of imperative instructions, describing exactly how PHP should validate.
- Validation logic is tightly coupled to each Value Object. Every Value Object contains its own validation code, which makes it difficult to share or reuse validations across different Value Objects.
- Composability is lacking. Because the checks are isolated inside each class, it is hard to combine or chain validation rules in a clean or elegant way.
- Non-linear code flow. Relying on exceptions for flow control leads to a non-linear and sometimes rather complex structure that can be challenging to maintain.
These issues become even more pronounced when dealing with entities made up of several properties. You are faced with some unappealing choices: validate each property individually and throw an exception at the first problem (which makes for a poor user experience), collect errors by hand (which quickly becomes tedious and error-prone), or bring in a heavy validation library as a dependency.
What if instead we could move validation logic into a library of reusable, composable validators? And what if we could accumulate all the validation errors, rather than stopping as soon as the first one occurs? And what if we can use them inside the Value Object (and not as an external check) so we keep the premise of "when the object is instantiated, the value is formally valid"?
Thanks to PHP 8.5, all of this is now possible. I'll show you how, step by step.
Our goal is to end up with something like the following:
readonly final class Age
{
// Private constructor
private function __construct(public int $value) {}
public static function create(mixed $value): Age|ErrorsBag
{
$context = IntegerValue::from($value)
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
return $context->isValid()
? new Age($context->getValue())
: $context->getErrors();
}
}
Even though the example is straightforward, there are a few important details to notice:
private function __construct(public int $value) {}
The constructor is made private to ensure that all instantiation goes through the create factory method.
$context = IntegerValue::from($value)
The input value is wrapped inside another object, which we will look at in more depth later.
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
Instead of rewriting the validation logic every time, we use a dedicated validation library. Each check can have a custom error message.
return $context->isValid()
? new Age($context->getValue())
: $context->getErrors();
The flow continues all the way to the end, where we check if the value is valid or not. Even at this point, we do not throw exceptions. Instead, we return either the Value Object instance or the errors themselves.
Building Blocks
What I've shown so far is just the final usage. To better understand the example, we need to break it down into different "building blocks" to see what's behind it.
PHP 8.5 Pipes
PHP 8.5 introduces the pipe operator (|>), which allows you to pass the result of one expression as the first argument to the next:
$result = $value
|> function1(...)
|> function2(...)
|> function3(...);
The pipe operator enables a functional programming style that was previously awkward in PHP. It lets us chain operations in a way that's both readable and composable. The ... operator tells PHP that the function requires the result of the previous function as its argument. The only limitation is that the function can have only one argument.
For validation, this is transformative because it lets us express validation as a pipeline:
$value
|> isString()
|> minLength(10)
|> maxLength(200)
|> startsWith('foo')
Each step in the pipeline transforms the value (or context) and passes it to the next step. This is the foundation of our functional validation approach.
The Validation Context (A Functor)
Instead of throwing exceptions immediately, we need a way to accumulate errors as validation progresses. This is where ValidationContext comes in. From a functional programming perspective, this is actually a functor.
In functional programming, a functor is a type that can be mapped over. It wraps a value and allows transformations while preserving its structure. For validation, we create a functor that holds both the value being validated and any errors that have been collected.
As PHP developers, we're not used to thinking in terms of "functors," so I'll use the term "context" which is perhaps more understandable. A context object that maintains information and accumulates any errors throughout the validation process.
readonly abstract class ValidationContext
{
private function __construct(
private mixed $value,
private array $errors
) {}
protected static function of(mixed $value): self
{
return new self($value, []);
}
public function validate(callable $predicate, string $errorMessage): self
{
$isValid = $predicate($this->value);
if (!$isValid) {
return $this->addError($errorMessage);
}
return $this; // Continue with the same context
}
public function getErrors(): ErrorsBag
{
// Convert error messages to ErrorsBag
}
public function isValid(): bool
{
return empty($this->errors);
}
}
The context flows through the pipe, accumulating errors as it goes. If validation fails, we add an error but continue processing. This allows us to collect all validation errors, not just the first one.
The context is immutable: each validation step returns a new instance, making it safe to pass through pipes. This immutability is what makes it a functor: we can transform it (add errors, change the value) while maintaining its structure (it's always a ValidationContext).
A Library of Reusable Validators
Instead of writing validation logic inside each Value Object, we create a library of reusable validators.
Consider the traditional approach:
// VALIDATION FOR AGE
($value < 0) or throw new InvalidArgumentException("Age cannot be negative");
($value > 150) or throw new InvalidArgumentException("Age cannot exceed 150");
// VALIDATION FOR PRICE
($value < 0) or throw new InvalidArgumentException("Price cannot be negative");
($value > 100000) or throw new InvalidArgumentException("Price cannot exceed 1000.00€");
Notice the duplication? Both check for minimum and maximum, but the logic is embedded in each class.
Here's how both Age and Price look using the functional approach with a validator library:
// VALIDATION FOR AGE
$context = IntegerValue::from($value)
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
// VALIDATION FOR PRICE
$context = IntegerValue::from($value)
|> IntegerValue::min(0, "Price cannot be negative")
|> IntegerValue::max(100000, "Price cannot exceed 1000.00€");
For such simple examples, it might not seem worth it. But when we consider more complex validation logic (email, password, specific formats), this approach allows us to avoid reinventing the wheel every time and, more importantly, to have much more readable code, as it makes explicit what validation is being performed.
Compare this to the traditional approach:
- No embedded validation logic - validation is delegated to reusable validators
- Declarative style - we describe what we validate, not how
- Composable - validators are chained together elegantly
- Error accumulation - all validation errors are collected, not just the first
- Reusable validators -
IntegerValue::min()andIntegerValue::max()are used by bothAgeandPrice(and any other Value Object that needs them) - Custom error messages - we can set custom messages for every different value object, giving more context
But what does a validator like this look like? Here is my suggestion:
readonly class IntegerValue extends ValidationContext
{
public static function from(mixed $value): IntegerValue
{
return $value
|> IntegerValue::of(...)
|> IntegerValue::isInteger();
}
public static function isInteger(?string $errorMessage = null): \Closure
{
return static function (ValidationContext $context) use ($errorMessage) {
$message = $errorMessage ?? "Value must be an integer";
$newContext = $context->validate(
fn(mixed $value) => is_int($value),
$message
);
return ($newContext->isValid())
? IntegerValue::of(intval($context->getValue()))
: $context;
};
}
public static function min(int $min, ?string $errorMessage = null): \Closure
{
$message = $errorMessage ?? "Value must be at least {$min}";
return static fn(ValidationContext $context) => $context->validate(
fn(int $value) => $value >= $min,
$message
);
}
public static function max(int $max, ?string $errorMessage = null): \Closure
{
$message = $errorMessage ?? "Value must be at most {$max}";
return static fn(ValidationContext $context) => $context->validate(
fn(int $value) => $value <= $max,
$message
);
}
public static function between(int $min, int $max, ?string $errorMessage = null): \Closure
{
$message = $errorMessage ?? "Value must be between {$min} and {$max}";
return static fn(ValidationContext $context) => $context->validate(
fn($value) => $value >= $min && $value <= $max,
$message
);
}
}
The validator methods work in two ways:
-
from()is a factory method that takes a raw value and creates aValidationContext, starting the validation pipeline - Methods like
min(),max(),isInteger(), andbetween()are static methods that return closures. These closures take a context and return a context, making them perfect for use in pipe chains
This pattern makes the validators completely reusable across any Value Object that needs integer validation. You compose them in a pipe, and each step transforms the context as it flows through.
The same principle applies to strings:
readonly class StringValue extends ValidationContext
{
public static function from(mixed $value): StringValue {}
public static function isString(?string $errorMessage = null): \Closure { }
public static function minLength(int $min, ?string $errorMessage = null): \Closure { }
public static function maxLength(int $max, ?string $errorMessage = null): \Closure { }
public static function email(?string $errorMessage = null): \Closure { }
public static function hasUppercase(?string $errorMessage = null): \Closure { }
// ... and many more
}
Instead of embedding validation logic in each Value Object, we compose validators from a shared library. This means:
- DRY principle: Write validation logic once, use it everywhere
- Consistency: Same validators produce the same behavior
- Testability: Test validators independently
- Maintainability: Update validation logic in one place
Error Accumulation
Traditional validation stops at the first error. But with the functor approach, we can accumulate all errors. Password validation is a perfect example because a weak password can fail multiple rules at once:
// Traditional approach - stops at first error
try {
$password = new Password("weak"); // Throws immediately on first failure
} catch (InvalidArgumentException $e) {
// Only see: "Password must be at least 8 characters long"
// Never know it also lacks uppercase, numbers, special characters, etc.
}
// New approach - collects all errors
$context = "weak"
|> StringValue::from(...)
|> StringValue::minLength(8, "Password must be at least 8 characters long") // Fail
|> StringValue::maxLength(20, "Password cannot exceed 20 characters")
|> StringValue::hasUppercase("Password must contain at least one uppercase letter") // Fail
|> StringValue::hasLowercase("Password must contain at least one lowercase letter")
|> StringValue::hasNumber("Password must contain at least one number") // Fail
|> StringValue::hasSpecialCharacter("Password must contain at least one special character"); // Fail
if ($context->hasErrors()) {
foreach ($context->getErrors()->getErrors() as $error) {
echo $error->message . "\n";
}
// Shows all four errors:
// - Password must be at least 8 characters long
// - Password must contain at least one uppercase letter
// - Password must contain at least one number
// - Password must contain at least one special character
}
This is particularly useful for user-facing validation where you want to show all issues at once, rather than making users fix one error at a time. With password validation, users can see all the requirements they need to meet in a single feedback cycle.
The context persists throughout the pipeline, collecting errors at each step. Only at the end do we check for errors.
Union Types Instead of Either (Monad)
In functional programming, the Either monad is commonly used to represent a value that can be one of two types (typically success or failure). It's a powerful abstraction that helps when working with pipelines and other functional patterns.
In PHP, it is possible to implement the Either monad, but unfortunately, neither the IDE nor the language itself provides native support for it.
Starting from PHP 8.0, we have a similar but different feature: union types. This feature has several limitations compared to the power that the Either monad provides. But for the use case we are exploring, I think union types provide a more expressive and native alternative.
Here are some real examples to illustrate the difference:
The Either Monad Approach
With the Either monad, you would typically wrap results in a monadic container that supports functional composition:
// Usage with Either monad
public static function create(mixed $value): Either
{
$context = $value
|> IntegerValue::from(...)
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
return $context->isValid()
? Either::right(new Age($context->getValue()))
: Either::left($context->getErrors());
}
Using it requires method calls, loses type information, and also loses readability:
$result = Age::create(25);
// Functional composition example (but still loses types)
$result = Age::create(25)
->map(fn($age) => $age->value * 2) // Transform if valid
->flatMap(fn($value) => Age::create($value)); // Chain another validation
// Imperative example
if ($result->isRight()) {
$age = $result->getRight(); // IDE doesn't know this is Age
echo $age->value; // No type safety, no autocomplete
} elseif ($result->isLeft()) {
$errors = $result->getLeft(); // IDE doesn't know this is ErrorsBag
foreach ($errors->getErrors() as $error) { // No type hints
echo $error->message;
}
}
We could improve the Either monad approach using static analysis tools like Psalm or PHPStan with docblock generics, but it still lacks readability and requires knowledge of the functional paradigm. I want to hide as much complexity as possible and keep the usage of Value Objects clean and familiar to regular PHP programmers.
The Union Type Approach
Instead of wrapping results in an Either monad, we can use union types directly:
public static function create(mixed $value): Age|ErrorsBag
{
$context = $value
|> IntegerValue::from(...)
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
return $context->isValid()
? new Age($context->getValue())
: $context->getErrors();
}
The return type Age|ErrorsBag is more expressive than the Either monad because:
- Native language support: No need for wrapper classes or monadic operations
- Direct type checking: Use
instanceofdirectly, no need forisLeft()orisRight()methods - Clearer intent: The types themselves tell you what you're working with
- Better IDE support: Autocomplete and type hints work out of the box
Using it is straightforward:
$result = Age::create(25);
if ($result instanceof Age) {
// Handle valid age - IDE knows $result is Age here
echo $result->value; // Full autocomplete support
} elseif ($result instanceof ErrorsBag) {
// Handle validation errors - IDE knows $result is ErrorsBag here
foreach ($result->getErrors() as $error) {
echo $error->message;
}
}
This is type-safe and explicit. The type system ensures you can't accidentally ignore errors or use an invalid value. The IDE provides full autocomplete and type checking at each branch.
Conclusion
Now that we've analyzed each building block one by one, the initial example takes on a whole new meaning:
readonly final class Age
{
// Private constructor
private function __construct(public int $value) {}
public static function create(mixed $value): Age|ErrorsBag
{
$context = $value
|> IntegerValue::from(...)
|> IntegerValue::min(0, "Age cannot be negative")
|> IntegerValue::max(150, "Age cannot exceed 150");
return $context->isValid()
? new self($context->getValue())
: $context->getErrors();
}
}
Notice what's not in this class:
- No
ifstatements - No exception throwing
- No embedded validation logic
Instead, we:
- Use pipes to chain operations
- Use reusable validators from a library
- Use context (functor) to accumulate errors
- Return a union type for type safety
The validation is declarative: we're describing what we want to validate, not how. The pipe operator makes the flow obvious: start with a value, validate it's an integer, check minimum, check maximum.
Using it is equally elegant:
// Valid age - returns Age object
$age = Age::create(25);
if ($age instanceof Age) {
echo "Age: {$age->value}"; // 25
}
// Invalid age - returns ErrorsBag with all errors
$result = Age::create(-5);
if ($result instanceof ErrorsBag) {
foreach ($result->getErrors() as $error) {
echo $error->message . "\n";
}
// Output: "Age cannot be negative"
}
This represents a shift from exception-based validation. Instead of "fail fast," we "collect all failures."
So far, we've only covered Value Objects. The next step in this discussion will be entities: how to handle multiple Value Objects together. But we'll see that in the next article.
Top comments (0)