Table Of Contents
Problem
You need to create immutable objects where properties cannot be changed after initialization, but private properties with getters feel verbose.
Solution
// Basic readonly properties
class User {
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly string $email,
public readonly \DateTime $createdAt
) {}
}
$user = new User(
id: 'user-123',
name: 'John Doe',
email: 'john@example.com',
createdAt: new \DateTime()
);
// These will cause fatal errors:
// $user->id = 'new-id'; // Fatal error
// $user->name = 'Jane Doe'; // Fatal error
// Value objects with readonly properties
class Money {
public function __construct(
public readonly int $amount,
public readonly string $currency
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount cannot be negative');
}
}
public function add(Money $other): Money {
if ($this->currency !== $other->currency) {
throw new InvalidArgumentException('Currency mismatch');
}
return new Money($this->amount + $other->amount, $this->currency);
}
public function format(): string {
return number_format($this->amount / 100, 2) . ' ' . $this->currency;
}
}
$price = new Money(1999, 'USD');
$tax = new Money(160, 'USD');
$total = $price->add($tax);
echo $total->format(); // 21.59 USD
// Configuration objects
class DatabaseConfig {
public function __construct(
public readonly string $host,
public readonly string $database,
public readonly string $username,
public readonly string $password,
public readonly int $port = 3306,
public readonly array $options = []
) {}
public function getDsn(): string {
return "mysql:host={$this->host};port={$this->port};dbname={$this->database}";
}
}
$config = new DatabaseConfig(
host: 'localhost',
database: 'myapp',
username: 'user',
password: 'secret',
options: [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
// API response objects
class ApiResponse {
public function __construct(
public readonly int $statusCode,
public readonly array $data,
public readonly array $headers = [],
public readonly ?string $error = null
) {}
public function isSuccess(): bool {
return $this->statusCode >= 200 && $this->statusCode < 300;
}
public function toArray(): array {
return [
'status_code' => $this->statusCode,
'data' => $this->data,
'headers' => $this->headers,
'error' => $this->error
];
}
}
// Event objects
class OrderPlaced {
public function __construct(
public readonly string $orderId,
public readonly int $userId,
public readonly Money $total,
public readonly array $items,
public readonly \DateTime $placedAt
) {}
public function getEventName(): string {
return 'order.placed';
}
}
// Coordinate system
class Point {
public function __construct(
public readonly float $x,
public readonly float $y
) {}
public function distanceTo(Point $other): float {
return sqrt(
pow($other->x - $this->x, 2) +
pow($other->y - $this->y, 2)
);
}
public function move(float $deltaX, float $deltaY): Point {
return new Point($this->x + $deltaX, $this->y + $deltaY);
}
}
$origin = new Point(0, 0);
$point = new Point(3, 4);
$distance = $origin->distanceTo($point); // 5.0
// Mixed readonly and mutable properties
class Product {
private float $cachedPrice;
public function __construct(
public readonly string $id,
public readonly string $name,
public readonly Money $basePrice,
public string $description = '',
public bool $isActive = true
) {
$this->cachedPrice = $this->calculatePrice();
}
private function calculatePrice(): float {
// Complex price calculation
return $this->basePrice->amount * 1.1; // Add 10% markup
}
public function updateDescription(string $description): void {
$this->description = $description; // Allowed - not readonly
}
public function activate(): void {
$this->isActive = true; // Allowed - not readonly
}
}
// Readonly with lazy initialization
class Report {
private ?array $processedData = null;
public function __construct(
public readonly string $id,
public readonly array $rawData,
public readonly \DateTime $generatedAt
) {}
public function getProcessedData(): array {
if ($this->processedData === null) {
$this->processedData = $this->processRawData();
}
return $this->processedData;
}
private function processRawData(): array {
// Complex data processing
return array_map(fn($item) => strtoupper($item), $this->rawData);
}
}
// Data Transfer Objects (DTOs)
class CreateUserRequest {
public function __construct(
public readonly string $name,
public readonly string $email,
public readonly string $password,
public readonly array $roles = ['user']
) {}
public function validate(): array {
$errors = [];
if (empty($this->name)) {
$errors[] = 'Name is required';
}
if (!filter_var($this->email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'Invalid email format';
}
if (strlen($this->password) < 8) {
$errors[] = 'Password must be at least 8 characters';
}
return $errors;
}
}
// Readonly classes (PHP 8.2+)
readonly class ImmutableUser {
public function __construct(
public string $id,
public string $name,
public string $email,
public \DateTime $createdAt
) {}
public function withName(string $name): self {
return new self($this->id, $name, $this->email, $this->createdAt);
}
public function withEmail(string $email): self {
return new self($this->id, $this->name, $email, $this->createdAt);
}
}
// Error handling with readonly
class ValidationError {
public function __construct(
public readonly string $field,
public readonly string $message,
public readonly string $code,
public readonly mixed $value = null
) {}
public function toArray(): array {
return [
'field' => $this->field,
'message' => $this->message,
'code' => $this->code,
'value' => $this->value
];
}
}
class ValidationResult {
public function __construct(
public readonly bool $isValid,
public readonly array $errors = []
) {}
public function getFirstError(): ?ValidationError {
return $this->errors[0] ?? null;
}
public function getErrorsForField(string $field): array {
return array_filter($this->errors, fn($error) => $error->field === $field);
}
}
// Cache key object
class CacheKey {
public function __construct(
public readonly string $prefix,
public readonly string $identifier,
public readonly array $tags = []
) {}
public function toString(): string {
return $this->prefix . ':' . $this->identifier;
}
public function withTag(string $tag): self {
return new self($this->prefix, $this->identifier, [...$this->tags, $tag]);
}
}
// Query objects
class DatabaseQuery {
public function __construct(
public readonly string $table,
public readonly array $select = ['*'],
public readonly array $where = [],
public readonly array $orderBy = [],
public readonly ?int $limit = null
) {}
public function toSql(): string {
$sql = "SELECT " . implode(', ', $this->select) . " FROM {$this->table}";
if (!empty($this->where)) {
$conditions = array_map(fn($field, $value) => "$field = ?",
array_keys($this->where), $this->where);
$sql .= " WHERE " . implode(' AND ', $conditions);
}
if (!empty($this->orderBy)) {
$sql .= " ORDER BY " . implode(', ', $this->orderBy);
}
if ($this->limit !== null) {
$sql .= " LIMIT {$this->limit}";
}
return $sql;
}
public function getBindings(): array {
return array_values($this->where);
}
}
$query = new DatabaseQuery(
table: 'users',
select: ['id', 'name', 'email'],
where: ['active' => 1, 'role' => 'admin'],
orderBy: ['created_at DESC'],
limit: 10
);
// Performance considerations
class BenchmarkResult {
public function __construct(
public readonly string $operation,
public readonly float $executionTime,
public readonly int $memoryUsage,
public readonly \DateTime $timestamp
) {}
public function format(): string {
return sprintf(
"%s: %.4fs, %s memory at %s",
$this->operation,
$this->executionTime,
$this->formatBytes($this->memoryUsage),
$this->timestamp->format('Y-m-d H:i:s')
);
}
private function formatBytes(int $bytes): string {
$units = ['B', 'KB', 'MB', 'GB'];
$factor = floor(log($bytes, 1024));
return sprintf("%.2f %s", $bytes / pow(1024, $factor), $units[$factor]);
}
}
Explanation
Readonly properties can only be assigned once during object construction. They provide immutability without the boilerplate of private properties and getters.
Use readonly for value objects, DTOs, configuration objects, and any data that shouldn't change after creation. Combine with constructor property promotion for maximum conciseness.
Share this article
Add Comment
No comments yet. Be the first to comment!