Lesson 81 of 100
Classes15 min5 checksPHP 8.5

Build Small Value Objects in PHP 8.4

Learn to wrap a validated value in a small immutable class and expose a predictable scalar form, then use it to normalize hex colors.

01 · THE IDEAREAD → TRY → LEARN

Why value objects?

A plain string or integer carries no promise. A variable named $percent could hold -5, 4000 or "banana", and every function that receives it has to wonder. A value object is a small class that wraps one value (or a few related values), checks it once in the constructor, and from then on guarantees it is valid. Anywhere you receive the object, you can trust it.

Two habits define a good value object:

  1. It validates on creation. An invalid object can never exist.
  2. It is immutable. Once built, it never changes. A different value means a different object.

The class basics from the manual apply directly: a class has properties and methods, and new creates an instance. A constructor may throw an exception, in which case no usable object is produced.

A first value object

PHP offers constructor promotion and readonly properties, which make this style short. A readonly property can be set only once, from inside the class. Declaring the class final stops subclasses from weakening the rules.

Example
<?php
final class Percent
{
    public function __construct(public readonly int $value)
    {
        if ($value < 0 || $value > 100) {
            throw new InvalidArgumentException('Percent must be 0-100');
        }
    }

    public function label(): string
    {
        return $this->value . '%';
    }
}

echo (new Percent(45))->label(), PHP_EOL;
Output
45%

The label() method gives a predictable scalar representation. Callers do not build the string themselves, so the format lives in one place.

Rejecting bad input

The constructor is the gatekeeper. When the check fails it throws, and the caller decides how to react.

Example
<?php
final class Percent
{
    public function __construct(public readonly int $value)
    {
        if ($value < 0 || $value > 100) {
            throw new InvalidArgumentException('Percent must be 0-100');
        }
    }
}

try {
    new Percent(120);
} catch (InvalidArgumentException $e) {
    echo $e->getMessage(), PHP_EOL;
}
Output
Percent must be 0-100

Boundaries matter here: 0 and 100 are valid, -1 and 101 are not. Always test both sides of each edge.

Changing a value without mutation

Because properties are readonly, you do not modify an object. You return a new one, often from a method named withSomething(). The original is untouched, so any code still holding it is safe.

Example
<?php
final class Percent
{
    public function __construct(public readonly int $value)
    {
        if ($value < 0 || $value > 100) {
            throw new InvalidArgumentException('Percent must be 0-100');
        }
    }

    public function withValue(int $value): self
    {
        return new self($value);
    }
}

$a = new Percent(10);
$b = $a->withValue(20);
echo json_encode([$a->value, $b->value]), PHP_EOL;
Output
[10,20]

Notice that withValue() goes through the constructor again, so the new object is validated by the same rules.

Named constructors and normalization

Sometimes invalid input is expected, for example text typed by a user. Throwing for every typo may be heavy. A common pattern is a private constructor plus a static factory such as tryFrom() that returns null on failure. The factory also normalizes: it trims, lowercases or expands the input so equal values always end up with the same representation. That is what makes two objects comparable by their scalar form.

Exercise contract

You will model a hex color. Write the function normalizeColor(string $input): ?string. Inside it, use a small value object (a class you define in the same file) so the rules live in one place. The contract:

  • Trim surrounding whitespace and lowercase the text.
  • Allow at most one leading #; remove it.
  • The remaining text must be exactly 3 or 6 hexadecimal digits (0-9, a-f).
  • A 3-digit color expands by doubling each digit, so fa0 becomes ffaa00.
  • Return the result with a leading #, for example #ffaa00.
  • Return null for anything invalid, including the empty string and a doubled ##.

The tests check the returned JSON value, so return null rather than printing a message. Helpful built-ins are trim(), strtolower(), str_starts_with(), substr(), strlen() and ctype_xdigit().

Recap

  • A value object validates once and is then trustworthy everywhere.
  • Use readonly properties and final classes to keep it immutable.
  • Expose one predictable scalar form such as label() or a value property.
  • Produce changed copies instead of mutating, and normalize input so equal values look identical.
Before you start

Functions · Arrays · Types

You’ll practice
  • Validate a value and expose a predictable scalar representation
  • Implement and test a deterministic PHP 8.4 function
Your task

Make normalizeColor() pass all 5 checks. Run your code as often as you like, then check your answer.

exercise.phpSaved in this browserPHP 8.5
CONSOLE & CHECKS● Ready

Ready when you are

Your output will appear here.
Make it work. Then make it pass.

PHP loads on your first run. Each check starts fresh. Your code stays in this browser; clearing site data removes your progress. A check or the task looks wrong? Report a problem with this exercise.