← All writing

A readonly PHP DTO is a data boundary, not automatic validation

PHP 8.3+Sources checked 2026-09-12

An import job passes a product code and a quantity through several services. A small readonly object can make that contract easier to follow. It still needs to reject impossible values at construction.

Make invalid states harder to create

<?php
final readonly class StockUpdate
{
    public function __construct(
        public string $sku,
        public int $quantity,
    ) {
        if ($sku === '' || $quantity < 0) {
            throw new InvalidArgumentException('Invalid stock update');
        }
    }
}
$update = new StockUpdate('AX-204', 0);
assert($update->quantity === 0);
try {
    $update->quantity = 10;
    throw new RuntimeException('Expected readonly write to fail');
} catch (Error) {
    echo "quantity cannot be reassigned", PHP_EOL;
}

Zero is allowed because an out-of-stock update is meaningful. An empty SKU is rejected because this particular value object cannot identify a product without one.

Keep normalization outside the constructor

If the supplier contract permits surrounding spaces, normalize them before constructing this object. If codes are case-sensitive, preserve case. A constructor should not quietly rewrite an identifier according to assumptions the caller cannot see.

The object describes an accepted update, not whether a user may apply it. Authorization, tenant ownership, and stale-version checks remain responsibilities of the application service and persistence layer.

Know the depth of the guarantee

Readonly prevents reassignment of a property. It does not make every referenced object deeply immutable. This example uses scalar properties partly to keep that distinction simple. A mutable collection stored inside another readonly object can still change internally.

Test construction with zero, negative quantities, and an empty identifier. Test the behavior you depend on rather than adding a DTO merely to move fields into another file. The benefit is a clear, enforced boundary that callers can understand.

PHP's readonly class documentation describes the language restrictions. This example requires no framework or database.