Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

PHP Readonly Classes Real World Uses

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.

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.

Readonly Class Use CasesHTTP InputJSON, formsReadonly DTOTyped, frozenDomain LogicServices, rulesResponseJSON, viewsValue ObjectsMoney, EmailAPI PayloadsGateway dataConfig SnapshotsFeature flagsRule: construct once, never mutatePHP 8.2+ readonly class keyword
PHP readonly classes real world uses: freeze request data before it crosses service boundaries.

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

  1. Accept raw input in the controller or Form Request.
  2. Validate with Laravel rules or Symfony constraints.
  3. Map validated data into a readonly DTO via a named constructor or factory.
  4. Pass the DTO to a service that returns a result DTO or domain outcome.
  5. 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.

AspectReadonly property (public readonly)Readonly class (readonly class)
ScopeSingle property on any classEvery property on that class must be readonly
Dynamic propertiesStill possible on non-readonly classesForbidden on readonly classes
InheritanceChild can add mutable propertiesNon-readonly child cannot widen mutability
Best fitEntities with one or two frozen fieldsPure DTOs and value objects
PHP version8.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.

Readonly Property vs ClassNormal Classreadonly string $idmutable string $statusdynamic props allowedreadonly classreadonly string $idreadonly string $statusno dynamic propsChoose class-level readonly for pure DTOs
Readonly classes enforce immutability on every property; single readonly properties leave gaps.

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.

Laravel + Readonly DTO FlowRouteFormRequestReadonlyDTOServiceJSONReal production usesPayment callback payloads (eSewa, Khalti)Document upload metadata for client portalsSearch filters passed to repository queries
Readonly DTOs sit between Laravel Form Requests and services—see the eSewa integration guide for payment payloads.

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.

When to Use Readonly ClassesNew object?Crosses layer boundaryLives in one functionUse readonly class DTOArray or local variable OKMust update DB row?Use Eloquent, not readonly
Decision guide for PHP readonly classes real world uses: boundary objects yes, mutable persistence no.

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 DateTime or ArrayObject. 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 $items cannot be reassigned, but $dto->items[] = $x fails 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 $dto with __clone hacks 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 class in 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

A normal PHP class prefixed with the readonly keyword. Every instance property must be readonly, set once in the constructor, and cannot be reassigned afterward. Dynamic properties are forbidden. Available since PHP 8.2.

PHP 8.2 or higher. Readonly properties on individual fields arrived in PHP 8.1; readonly classes as a whole class modifier require 8.2+. Laravel 12 runs on PHP 8.2+, Laravel 13 needs PHP 8.3+, and Symfony 8.1 needs PHP 8.4.1—all support readonly classes.

Use them when an object represents a fact at a point in time: order totals, payment callback payloads, court-fee calculation inputs, or validated booking form data. They excel as immutable DTOs, value objects, API response wrappers, and config snapshots. Do not use them for database entities like Eloquent models that must change state across requests.

Readonly properties (PHP 8.1+) freeze a single field on an otherwise normal class; dynamic properties and mutable child properties can still exist elsewhere. Readonly classes (PHP 8.2+) require every property on that class to be readonly and forbid dynamic properties. Readonly classes suit pure DTOs and value objects; single readonly properties fit entities with one or two frozen fields.

Accept raw input in the controller or Form Request, validate with Laravel rules, then map validated data into a readonly DTO via a named constructor or factory like fromValidated(). Pass that DTO to a service returning a result DTO. Serialize to JSON or Blade without putting Eloquent or business logic inside the DTO. Keep arrays out of public properties when structured nested readonly classes work instead.

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—such as Money::add() returning a fresh instance—belongs on the readonly class itself.

Public readonly properties serialize with json_encode() on plain objects, or via JsonSerializable and Laravel API resources. Implement toArray() on the DTO for an explicit, stable output shape. That keeps API responses documented and predictable instead of relying on guessed array keys from decoded JSON.

No. You can mark a readonly class final yourself, which is a good default for DTOs. Inheritance rules still apply: a readonly class cannot extend a non-readonly class, and a non-readonly class cannot extend a readonly class. Composition over inheritance is simpler for DTO hierarchies.

No. Local temporaries inside one method can stay arrays. Cross-layer boundaries, queue payloads, payment callbacks, and public API contracts benefit most from readonly classes. The pattern replaces ad-hoc associative arrays at service boundaries—not every internal variable in your codebase.

Serialize a readonly DTO's public primitive properties into the job payload. On a production Laravel application, store primitive fields in the job and rebuild the DTO in handle(). That avoids stale Eloquent model references after queue delay, because models are mutable by design and should not be passed into readonly wrappers expecting frozen state.

Gateway callbacks arrive as POST bodies with vendor-specific keys. Map them immediately into a readonly PaymentCallbackData object at the first internal boundary, then verify signatures and amounts on typed fields. Wrapping the raw array the moment it enters your app prevents silent mutation before audit and verification logic runs.

Readonly stops property reassignment, not deep freezing. A readonly property holding DateTime or ArrayObject can still mutate internally. Arrays cannot be reassigned but nested contents need discipline—prefer lists of readonly DTOs. Do not make Eloquent models readonly; map models to DTOs at the repository exit. Without declare(strict_types=1) and constructor validation, readonly only freezes bad data.

A readonly class cannot extend a non-readonly class, and a non-readonly class cannot extend a readonly class either. A non-readonly child cannot widen mutability on a readonly parent. Plan composition over inheritance for DTO hierarchies, which aligns with how most enterprise application layers are structured today.

Store currency as integers in the smallest unit inside a readonly Money class—never float for NPR or USD checkout totals. Expose format() and add() helpers; add() returns a new instance while the original stays unchanged. Wrap external REST JSON into readonly envelopes like SmsGatewayResponse with a fromJson() factory so controllers depend on typed objects, not decoded arrays.

Constructor property promotion gives PHPStan explicit types on every field. Combine readonly classes with declare(strict_types=1), Form Requests, and PHPStan or Psalm in CI—not as a standalone fix. PHPUnit tests stay simple: construct the DTO, assert values, pass it to the service. Each test builds its own readonly input instead of shared mutable arrays that cause order-dependent failures.

Share this article

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.

Quick Contact Options
Choose how you want to connect me: