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 (7)

Collapse
 
xwero profile image
david duymelinck

I see the same problem here as with the hierarchical value objects, By making the checks generic it is likely they will move to a shared domain. And when the time comes the domain needs to be on a separate server, the ties to that shared domain can be a big problem.

I think the value object validate method can be used for both the validation and the instantiation of the value object.
With the method in the post, the create method and the validate method will have the same checks, only the output method is different.
I think the switch is calling the validate method with or without the data argument(s).

So the validate method could look something like this.

public static function validate(mixed $value = null): true|ErrorBag {
   $isInternal = $value === null;
   $errorbag = new Errorbag();

   if($isInteral) {
      $value = self::$value;
   }

   if($value < 0) {
     $message =  getMessage(AgeMessage::TooLow);

      if($isInternal) {
          throw Exception($message);
      }

     $errorBag->add($message);
  }

   // and so on

  if($errorBag->hasErrors()) { 
    return $errorBag;
  }

  return true;
}
Enter fullscreen mode Exit fullscreen mode

If it are quite a few checks the method can be substantial. But it is not more code than it needs to be. And the checks are contained.

For the validation of multiple value objects, and even some situation specific checks, just merge the error bags.

Collapse
 
cnastasi profile image
Christian Nastasi • Edited

I'm not sure I understand which use case you are referring to.

The checks are generic, but the chain of checks specific to a domain object is embedded in the object itself. I suppose that, when you talk about to "shared domain", you are referring to domain objects shared between bounded contexts. And when you say "needs to be in a separate server", you are referring to splitting your product into microservices or services.

In this case, you can duplicate the code or create a domain objects library in order to share the same objects between projects.

I'm not sure what you did in your example. What's the need to return a boolean? And also raise an exception?

If you need exceptions, you can see in the repository that I implemented an Failable interface, which helps when you need to throw an exception anyway.

$age = new Age(-5)->orFail();
Enter fullscreen mode Exit fullscreen mode

Here how it's used

Collapse
 
xwero profile image
david duymelinck

I'm not sure what you did in your example. What's the need to return a boolean? And also raise an exception?

class Age 
{
     private static mixed $value;

     public function __construct(mixed $value)
     {
          self::$value = $value;
          self::validate(); // throw an error when the first check fails
     }
} 

// somewhere in the domain or application

Age::validate($input); // return errors or true depending on the checks are passing or not.
Enter fullscreen mode Exit fullscreen mode

Is that making it clearer?

Thread Thread
 
cnastasi profile image
Christian Nastasi

Yes, it does.

And yes, this is something I didn't put in the article, for simplicity, but I already did in the repository. Here is how I implemented it:

<?php

namespace CN\FunctionalValidators\Examples;

use ValueObjects\Errors\ErrorsBag;
use ValueObjects\Errors\Failable;
use ValueObjects\Errors\FailableSuccess;
use ValueObjects\Validators\IntegerValue;

readonly final class Age implements Failable
{
    use FailableSuccess;
    private function __construct(public int $value)
    {
    }

    public static function create(mixed $value): Age|ErrorsBag
    {
        $context = self::validate($value);

        return $context->isValid()
            ? new self($context->getValue())
            : $context->getErrors();
    }

    public static function validate(int $value): IntegerValue
    {
        return $value
            |> IntegerValue::from(...)
            |> IntegerValue::min(0, "Age cannot be negative")
            |> IntegerValue::max(150, "Age cannot exceed 150");
    }
}
Enter fullscreen mode Exit fullscreen mode

Similar, but without throwing an error, that is not something I want, because then when I have to work with entities, I need that nothing interrupts the flow, so I can accumulate all the errors.

Thread Thread
 
xwero profile image
david duymelinck • Edited

There are a few problems I see with your Age class.
new Age(-5) will be in an invalid state, because it isn't fully validated.
And Age::create('not a number') will trigger an exception because the value of the validate method is a more specific type than that of the create method.

The bigger picture in my case is that a DTO provides the input. And the attributes on the DTO properties tell the validation service which checks need to happen. If every check passes then the entities and value objects are filled.
So the validation will happen twice. It might seem wasteful, but this way leaves no room for error.

Thread Thread
 
cnastasi profile image
Christian Nastasi

🙂 If you look closer, you can see that the constructor of Age is private. The only way to have an instance of Age is through the factory method create.

The create argument is mixed because I don't know what kind of data I will receive. And this is why, inside the from of the IntegerValue, there's a type check.

Also, Value Objects and Entities are not DTOs; they are similar, but they have different semantics. Value Objects and Entities represent domain concepts, so they cannot be invalid. DTOs, on the other hand, are just data structures; they can be invalid.

Following your thoughts... what's the need for double validation if the validation logic is the same?

Thread Thread
 
xwero profile image
david duymelinck

constructor of Age is private

I overlooked that. I never make the constructor private, so that created a blindspot.
I see the use of it. But I don't like breaking the common way to instantiate an object.

And this is why, inside the from of the IntegerValue

a string will never reach the from method, because the validate value is typed as an int.

Value Objects and Entities are not DTOs

That is not what I mentioned. Please read what I wrote again.

the need for double validation if the validation logic is the same

As I mentioned before the validate handles two different ways of error output, either the possibility of returning multiple errors. Or stopping the instantiation in its tracks by throwing an error.

When someone creates a process that changes the value of the DTO in between the validation and the instantiation it will never result in an invalid value object.
Or if someone just instantiates the object.

The main idea is to get two functionalities out of one method, validation and preventing an invalid state.