A readonly PHP DTO is a data boundary, not automatic validation
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.