
September 07, 2026
12 min read
By Kokil Thapa | Last reviewed: September 2026
Mutable objects cause silent bugs. A controller tweaks a model mid-request, a service passes the same array by reference, and your audit trail no longer matches reality. PHP readonly classes real world uses start where you need small, typed bundles of data that never change after construction. Since PHP 8.2, you can mark an entire class readonly instead of tagging every property. On production web applications in Nepal and client APIs worldwide, that pattern has replaced half my ad-hoc associative arrays.
readonly class in PHP 8.2+ so every property is set once in the constructor and cannot be reassigned afterward.What Are PHP Readonly Classes and When Should You Use Them?
A readonly class is a normal PHP class prefixed with the readonly keyword. Every instance property must be readonly. You cannot add a non-readonly property later. Dynamic properties are forbidden on readonly classes in PHP 8.2 and later.
Use them when the object represents a fact at a point in time: an order total, a payment callback payload, a court-fee calculation input. Do not use them for entities that must change state in the database, like an Eloquent model row you update across requests.
PHP 8.5 is the current anchor release. Laravel 13 requires PHP 8.3 or higher. Laravel 12 runs on PHP 8.2+, so readonly classes are available on every stack I ship today. Symfony 8.1 needs PHP 8.4.1; readonly classes work there too.
The official PHP manual describes readonly classes under object basics. Read the PHP readonly class documentation before you refactor large codebases. The original RFC on wiki.php.net explains the design trade-offs if you need the full rationale.
Minimum syntax
<?php
declare(strict_types=1);
readonly class Money
{
public function __construct(
public int $amountPaisa,
public string $currency = 'NPR',
) {}
}
$fee = new Money(500000); // Rs 5,000.00 stored as paisa
// $fee->amountPaisa = 1; // Error: Cannot modify readonly property Constructor property promotion works well here. You write less boilerplate and PHPStan sees explicit types. That pairs cleanly with static analysis practices covered in our PHPStan level 9 guide.
How Do You Build DTOs with PHP Readonly Classes?
Data Transfer Objects carry data across layers. They should not contain business rules or database calls. Readonly classes enforce that boundary because you cannot sneak in mutable state after construction.
On a legal-tech portal I built, booking forms posted mixed fields: client name, service type, preferred date, and document flags. Mapping that raw input into a readonly DTO at the controller edge made validation and logging predictable. The same pattern appears in Court Marriage In Nepal style lead flows.
Step-by-step DTO pattern
- Accept raw input in the controller or Form Request.
- Validate with Laravel rules or Symfony constraints.
- Map validated data into a readonly DTO via a named constructor or factory.
- Pass the DTO to a service that returns a result DTO or domain outcome.
- Serialize the result DTO to JSON or a Blade view without touching Eloquent inside the DTO.
<?php
declare(strict_types=1);
namespace App\Data;
readonly class CreateBookingData
{
public function __construct(
public string $clientName,
public string $serviceSlug,
public string $preferredDate,
public bool $needsTranslation,
) {}
public static function fromValidated(array $input): self
{
return new self(
clientName: trim($input['client_name']),
serviceSlug: $input['service_slug'],
preferredDate: $input['preferred_date'],
needsTranslation: (bool) ($input['needs_translation'] ?? false),
);
}
} Keep arrays out of public DTO properties when you can. Nested readonly classes are allowed. A readonly class may contain another readonly class property. That gives you structured trees instead of loose nested arrays.
<?php
readonly class AddressData
{
public function __construct(
public string $district,
public string $municipality,
) {}
}
readonly class ClientProfileData
{
public function __construct(
public string $fullName,
public AddressData $address,
) {}
} For quick JSON debugging during development, paste DTO output through the JSON formatter tool on this site. It catches trailing commas and encoding issues before they hit Postman collections.
What Is the Difference Between Readonly Classes and Readonly Properties?
PHP 8.1 added readonly properties on otherwise normal classes. PHP 8.2 added readonly classes as syntactic sugar plus extra restrictions. Understanding both saves you from mixed patterns in one codebase.
| Aspect | Readonly property (public readonly) | Readonly class (readonly class) |
|---|---|---|
| Scope | Single property on any class | Every property on that class must be readonly |
| Dynamic properties | Still possible on non-readonly classes | Forbidden on readonly classes |
| Inheritance | Child can add mutable properties | Non-readonly child cannot widen mutability |
| Best fit | Entities with one or two frozen fields | Pure DTOs and value objects |
| PHP version | 8.1+ | 8.2+ |
A readonly class cannot extend a non-readonly class. A non-readonly class cannot extend a readonly class either. Plan composition over inheritance for DTO hierarchies. That aligns with how most enterprise application layers are structured today.
Property hooks in PHP 8.4 add another layer. They can intercept reads and writes on individual properties. Read our PHP 8.4 property hooks guide if you mix hooks with readonly types. Do not assume hooks replace readonly classes; they solve different problems.
How Do Readonly Classes Work in Laravel and Symfony Applications?
Laravel does not ship a first-class DTO package in core, but readonly classes fit naturally beside Form Requests, actions, and single-purpose service classes. I use them at the boundary between HTTP and domain code on projects like Nepal Gift Card and booking systems built with Livewire.
Laravel controller example
<?php
namespace App\Http\Controllers;
use App\Data\CheckoutData;
use App\Services\CheckoutService;
use App\Http\Requests\CheckoutRequest;
class CheckoutController extends Controller
{
public function __invoke(CheckoutRequest $request, CheckoutService $checkout): array
{
$data = CheckoutData::fromValidated($request->validated());
return $checkout->process($data)->toArray();
}
} Return arrays or API resources from the controller. Keep Eloquent models out of readonly DTO constructors when possible. Models are mutable by design. Passing a model into a readonly wrapper still lets other code mutate the underlying row.
Queue jobs benefit too. Serialize a readonly DTO's public properties into the job payload. On a production Laravel application, I store primitive fields in the job and rebuild the DTO in handle(). That avoids stale model references after queue delay.
Symfony 8.1 controllers and Messenger handlers use the same idea. Map HTTP input to a readonly command object. Handlers return readonly result objects. Messenger serializes public properties cleanly when you configure transport encoders correctly.
Payment integrations are a strong fit. Gateway callbacks arrive as POST bodies with vendor-specific keys. Map them immediately into a readonly PaymentCallbackData object. Then verify signatures and amounts on typed fields. Our eSewa integration guide for PHP apps shows the raw callback shape; wrap that array the moment it enters your app.
Repository layers gain clarity when filters are readonly. Instead of passing array $filters that callers might mutate, pass readonly class ProductFilterData. That pattern complements the Laravel repository pattern guide without adding framework magic.
What Are the Best Real-World Patterns Beyond Basic DTOs?
DTOs are the headline use case. Production code also uses readonly classes for value objects, audit snapshots, and external API envelopes.
Money and typed primitives
Store currency amounts as integers in the smallest unit. Expose helpers on the readonly class. Never use float for NPR or USD totals on checkout flows.
<?php
readonly class Money
{
public function __construct(
public int $minorUnits,
public string $currency,
) {}
public function format(): string
{
$major = number_format($minorUnits / 100, 2);
return $this->currency === 'NPR'
? "Rs {$major}"
: "{$this->currency} {$major}";
}
public function add(Money $other): self
{
if ($this->currency !== $other->currency) {
throw new \InvalidArgumentException('Currency mismatch');
}
return new self($this->minorUnits + $other->minorUnits, $this->currency);
}
} Addition returns a new instance. The original stays unchanged. That is immutability doing real work, not a keyword for show.
External API response envelopes
REST clients often return decoded JSON arrays. Wrap them once:
<?php
readonly class SmsGatewayResponse
{
public function __construct(
public bool $success,
public string $messageId,
public ?string $errorCode,
) {}
public static function fromJson(array $payload): self
{
return new self(
success: ($payload['status'] ?? '') === 'ok',
messageId: (string) ($payload['id'] ?? ''),
errorCode: $payload['error'] ?? null,
);
}
} Your API development workflow stays consistent. Controllers and jobs depend on typed objects, not guessed array keys.
Feature config snapshots
Read runtime config into a readonly object at boot or request start. Pass that snapshot through services. You avoid mid-request config drift when .env caching or Octane workers reuse memory. This matters on Linux-hosted PHP-FPM pools where opcache and workers stay hot for hours.
Event payloads
Domain events should carry readonly data. Listeners receive facts, not handles they can rewrite. Pair this with the ideas in our Laravel events and listeners article. Keep events small. Large blobs belong in storage with an ID reference inside the readonly payload.
What Mistakes Break PHP Readonly Class Immutability?
Readonly stops property reassignment. It does not deep-freeze object graphs. Know the gaps before you trust readonly for security or audit compliance.
- Mutable nested objects: A readonly property can hold a
DateTimeorArrayObject. Those objects may still mutate internally. Prefer immutable value types or readonly nested DTOs. - Arrays are copy-on-write, not frozen: A readonly
public array $itemscannot be reassigned, but$dto->items[] = $xfails while mutating nested array contents via references still needs discipline. Use list-of-readonly-DTO instead of raw arrays for structured data. - Cloning with changes: PHP allows
clone $dtowith__clonehacks in non-readonly classes. Readonly classes disallow unserialization tricks less than you expect; treat wire formats carefully. See PHP serialization vulnerabilities for why untrusted unserialize is risky regardless. - ORM confusion: Do not make Eloquent models readonly. Hydration and saves expect mutation. Map models to readonly DTOs at the repository exit.
- Untyped inputs: Readonly without strict types and constructor validation just freezes bad data. Combine with Form Requests and explicit factories.
Weak typing still bites readonly code. Read PHP type coercion gotchas and enable declare(strict_types=1); in every new file. Run PHPStan or Psalm in CI via testing and optimization practices you already use for Laravel upgrades.
Testing readonly objects
PHPUnit tests stay simple. Construct the DTO, assert getters, pass it to the service. No setUp state that mutates shared fixtures. For regex-heavy string fields, validate formats with the regex tester before you codify patterns in factories.
On client portals such as Mijar Law Associates, document metadata DTOs made feature tests deterministic. Each test built its own readonly input. Shared mutable arrays had caused order-dependent failures earlier.
Key Takeaways
- Declare
readonly classin PHP 8.2+ when the object is a typed data bundle, not a database entity. - Map HTTP and gateway input into readonly DTOs at the first internal boundary, then pass only those objects downstream.
- Prefer nested readonly DTOs over associative arrays for structured payloads in Laravel and Symfony services.
- Return new readonly instances from methods like
Money::add()instead of mutating in place. - Watch shallow immutability: nested mutable objects and array contents can still change unless you design around it.
- Pair readonly classes with strict types, Form Requests, and static analysis—not as a standalone magic fix.
People Also Ask
Can readonly classes have methods?
Yes. Readonly classes support methods, including computed getters and factories that return new instances. They only forbid mutating instance properties after construction. Business logic that derives new immutable objects from existing ones belongs on the readonly class itself.
Do readonly classes work with JSON serialization?
Public readonly properties serialize with json_encode() on plain objects via JsonSerializable or Laravel API resources. Implement toArray() on the DTO for explicit output shape. That keeps API responses stable and documented.
Are readonly classes final by default?
No. You can mark a readonly class final yourself, which is a good default for DTOs. Inheritance rules still apply: readonly and non-readonly classes cannot extend each other in conflicting ways. Composition stays simpler.
Should I replace all arrays with readonly classes?
No. Local temporaries inside one method can stay arrays. Cross-layer boundaries, queue payloads, payment callbacks, and public API contracts benefit most. Overusing readonly types adds files without clarity gains.
Ship Immutable Boundaries on Your Next PHP Project
PHP readonly classes real world uses are not academic. They are the fastest way I know to stop layer leakage on Laravel 12 and 13 codebases running PHP 8.3 through 8.5. Start with one hot path: checkout, booking, or payment callback. Wrap the input in a readonly DTO this week. Keep Eloquent and HTTP concerns outside it.
If you want help refactoring an existing app or designing DTO boundaries for a new portal, review the portfolio for similar work or reach out via contact us. For greenfield custom software builds, readonly DTOs are part of the architecture conversation from day one—not a late refactor.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.


