DEV Community

Cover image for Value Objects in PHP 8: Let's introduce a functional approach
Christian Nastasi
Christian Nastasi

Posted on

Value Objects in PHP 8: Let's introduce a functional approach

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");
    }
}
Enter fullscreen mode Exit fullscreen mode

While this approach gets the job done, it does have several drawbacks:

  1. 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.
  2. Imperative, step-by-step style. The validation logic is written as a series of imperative instructions, describing exactly how PHP should validate.
  3. 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.
  4. 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.
  5. 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Even though the example is straightforward, there are a few important details to notice:

private function __construct(public int $value) {}
Enter fullscreen mode Exit fullscreen mode

The constructor is made private to ensure that all instantiation goes through the create factory method.

$context = IntegerValue::from($value)
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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(...);
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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€");
Enter fullscreen mode Exit fullscreen mode

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€");
Enter fullscreen mode Exit fullscreen mode

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() and IntegerValue::max() are used by both Age and Price (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
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

The validator methods work in two ways:

  • from() is a factory method that takes a raw value and creates a ValidationContext, starting the validation pipeline
  • Methods like min(), max(), isInteger(), and between() 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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}

Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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 instanceof directly, no need for isLeft() or isRight() 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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice what's not in this class:

  • No if statements
  • 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"
}
Enter fullscreen mode Exit fullscreen mode

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)