PHP 8.2 Readonly Classes: Protecting Data After It Is Created

When an application retrieves data from an API, database, or another external source, that data often gets passed through several layers of the application before it is ultimately used. Once that data has been created, however, there may be no reason for it to change.

For example, suppose an application retrieves information about a country from an API. Once that information has been converted into an object representing the country, allowing another part of the application to accidentally change the population, country code, or region could introduce bugs that are difficult to track down.

PHP 8.2 introduced readonly classes, which provide a convenient way to express this intent directly in the class definition. Instead of declaring each property individually as readonly, an entire class can be declared as readonly:

readonly class CountryDto

{

// ...

}

This article looks at how readonly classes work, how they improve on older property-level declarations, and how a Data Transfer Object (DTO) can use them alongside a country API example.

What Is a Readonly Class?

A readonly class is a class whose instance properties cannot be modified after they have been initialized.

Consider a simple class without readonly properties:

class CountryDto

{

public string $name;

public string $alpha3Code;

public int $population;

}

Nothing prevents another part of the application from changing the object's properties:

$country->population = 50000000;

That may be intentional in some applications, but it may also represent an accidental modification of data that was supposed to stay fixed as received from an API. With PHP 8.2, the class can instead be declared as readonly:

readonly class CountryDto

{

public string $name;

public string $alpha3Code;

public int $population;

}

Now the properties can be initialized once, but they cannot subsequently be changed. The readonly declaration effectively applies readonly behavior to all instance properties of the class automatically.

Readonly Classes vs. Readonly Properties

Before PHP 8.2, PHP supported individual readonly properties:

class CountryDto

{

public function __construct(

public readonly string $name,

public readonly string $alpha3Code,

public readonly int $population

) {}

}

While this worked, every single property had to be tagged individually. PHP 8.2 allows you to lift that declaration to the class level:

readonly class CountryDto

{

public function __construct(

public string $name,

public string $alpha3Code,

public int $population

) {}

}

This cleaner syntax is particularly useful when all instance properties are intended to be immutable, making the design intent immediately obvious to anyone reading the class declaration.

Building a Country DTO

A common use case for readonly classes is a Data Transfer Object (DTO), whose primary purpose is to carry data safely between different layers of an application.

Here is how we can structure our CountryDto using constructor property promotion:

readonly class CountryDto

{

public function __construct(

public string $name,

public string $alpha3Code,

public array $capital,

public int $population,

public string $region

) {}

/**

* Maps raw JSON/array data into the DTO safely, handling type normalization.

*/

public static function fromArray(array $data): self

{

$population = $data['population'] ?? 0;

if (!is_int($population)) {

$population = is_numeric($population)

? (int) $population

: 0;

}

$capital = $data['capital'] ?? [];

if (!is_array($capital)) {

$capital = [$capital];

}

return new self(

name: (string) ($data['name']['common'] ?? $data['name'] ?? ''),

alpha3Code: (string) ($data['cca3'] ?? $data['alpha3Code'] ?? ''),

capital: $capital,

population: $population,

region: (string) ($data['region'] ?? '')

);

}

}

Normalizing Data at the Boundary

The fromArray() method creates a stable boundary between loosely structured external data (like an API response) and our strict application objects:

  • Population coercion: Ensures that if an API returns a numeric string instead of an integer, it gets safely cast to an int.
  • Capital array normalization: Ensures $capital is always an array, even if the API returns a single string like "Ottawa", saving downstream code from repetitive type-checking.

Enforcing and Testing Immutability in Practice

Once the DTO is initialized via its constructor:

$canada = new CountryDto(

name: 'Canada',

alpha3Code: 'CAN',

capital: ['Ottawa'],

population: 40000000,

region: 'Americas'

);

The application can freely read its properties throughout its lifecycle:

echo "Country: {$canada->name}\n";

echo "Population: " . number_format($canada->population) . "\n";

However, if downstream code attempts to modify a property:

$canada->population = 50000000;

PHP immediately halts execution with a fatal error:

Fatal error: Cannot modify readonly property CountryDto::$population

This prevents objects from silently entering corrupted or unexpected states.

A Quick Note: Readonly Is Not Deep Immutability

It is important to keep one limitation in mind: readonly prevents reassignment of the property itself, but it does not automatically make nested objects deeply immutable.

If a readonly property contains a reference to a mutable object:

readonly class Example

{

public function __construct(

public SomeMutableObject $data

) {}

}

While you cannot reassign $example->data to a different object instance, the internal state inside SomeMutableObject could still potentially be modified unless that object is also designed to be immutable. For DTOs handling scalar values, arrays, and strings, this distinction rarely poses an issue, but it is worth remembering for complex object graphs.

Exploring the Complete Example

To see this pattern in a more realistic setting — including how a readonly DTO fits into an actual service layer, not just a standalone class — I've put together a complete example on Gist. It builds out a small CountryService that fetches a country record from a public API via curl, handles the network and HTTP-status edge cases explicitly, and only then hands the decoded response off to CountryDto::fromArray() for normalization. The example also includes the commented-out mutation attempt from earlier, so you can see the fatal error firsthand if you uncomment it and run the file locally. It's a useful reference for how readonly classes hold up once they're wired into something closer to production code, rather than just a DTO sitting on its own.

Conclusion

PHP 8.2's readonly classes provide a clean, elegant way to enforce an important architectural decision: this data represents a fixed snapshot in time and should not change after initialization.

By combining readonly classes with DTOs and boundary mapping (fromArray), you can consume messy external APIs, normalize the data cleanly, and pass the resulting objects through your application layers with absolute confidence that their state remains secure and unmodified.