PHP 8.6 Readonly Property Defaults: Cleaner DTOs and Interface Properties in Laravel

PHP 8.6 allows a readonly property default value. How defaults differ from constants and promoted parameters, and which Laravel DTO boilerplate they delete.

Steven Richardson
Steven Richardson
· 6 min read

I had an action class whose entire constructor existed to set one string: the queue name. No arguments, no injection, just $this->queue = 'imports'; sitting in a method that had no other reason to exist. A constant would have done it, except the interface required a property. PHP 8.6 finally lets me delete that constructor.

What changed: readonly property default values in PHP 8.6#

The Readonly Property Defaults RFC passed 24–0 in August 2026 and is already implemented in php-src. It introduces no new syntax. It removes one compile-time check.

Before 8.6:

final class ImportHandler
{
    // Fatal error: Readonly property ImportHandler::$queue cannot have default value
    public readonly string $queue = 'imports';
}

From 8.6:

final class ImportHandler
{
    // Perfectly legal
    public readonly string $queue = 'imports';
}

The same applies inside a readonly class, where every property is implicitly readonly:

final readonly class ImportHandler
{
    public string $queue = 'imports';
}

Everything else about defaults is unchanged. The value must still be a constant expression, type checks still apply, and inheritance and trait composition rules are untouched.

The restriction was never technical. The original readonly RFC rejected defaults because a readonly property with a default was "essentially the same as a constant, and thus not particularly useful" — and it explicitly flagged that future language additions might change that. Interface properties in PHP 8.4 are that addition.

A readonly property default value vs constants and promoted parameters#

Three ways to pin a fixed value to a class, and they are not interchangeable.

final class Example
{
    // 1. Class constant: static, shared, cannot satisfy an interface property
    public const QUEUE = 'imports';

    // 2. Readonly default (8.6): per-instance, satisfies `{ get; }`, frozen at construction
    public readonly string $queue = 'imports';

    // 3. Promoted parameter default: per-instance, caller can override
    public function __construct(
        public readonly string $connection = 'redis',
    ) {}
}

The distinction that matters in practice:

  • A constant is accessed on the class (Example::QUEUE). It can never satisfy public string $queue { get; } on an interface, because that contract is about instance property access.
  • A readonly default is an instance property. It satisfies a get-only interface property, and nothing — not even the constructor — can assign over it.
  • A promoted parameter default is a parameter default, not a property default. The RFC is explicit that this is unchanged. Callers can still pass a different value.

So the question to ask is: should anyone ever be able to supply this value? If yes, promotion. If no, a readonly default.

Satisfying interface properties#

This is the use case the RFC was written for. An interface declares a get-only property, implementations supply a fixed value:

interface Ingestor
{
    public string $name { get; }
    public string $queue { get; }
    public array $steps { get; }

    public function run(string $payload): void;
}

final readonly class ChangelogIngestor implements Ingestor
{
    public string $name = 'changelog';
    public string $queue = 'ingest';

    public array $steps = [
        ParseChangelog::class,
        NormaliseEntries::class,
        PersistEntries::class,
    ];

    public function run(string $payload): void
    {
        // …
    }
}

No constructor. No getter methods. No const that the interface can't see.

One limit worth knowing before you plan around it: a readonly property, with or without a default, satisfies { get; } but not { get; set; }. If the interface demands write access, readonly is the wrong tool — reach for asymmetric visibility instead.

Laravel examples#

The pattern shows up anywhere Laravel wants a class to describe itself.

A pipeline of action classes, each declaring its own queue and retry budget:

interface QueueableAction
{
    public string $queue { get; }
    public int $tries { get; }
}

final readonly class SyncStripeInvoices implements QueueableAction
{
    public string $queue = 'billing';
    public int $tries = 5;
}

final readonly class RebuildSearchIndex implements QueueableAction
{
    public string $queue = 'maintenance';
    public int $tries = 1;
}

A dispatcher can now read $action->queue through the interface without reflection, static:: gymnastics or a getQueue() method on every class.

Mixed objects work too — some values fixed, some injected:

final class ReportExport
{
    // Fixed by the class, not negotiable
    public readonly string $disk = 's3';
    public readonly string $format = 'csv';

    public function __construct(
        public readonly int $tenantId,
        public readonly CarbonImmutable $generatedAt,
    ) {}
}

That reads better than the pre-8.6 version, where $disk and $format were either constants (invisible to any interface) or promoted parameters with defaults (overridable by any caller who felt like it). If you're already building immutable value objects with readonly classes, this slots straight into that style.

Gotchas and Edge Cases#

The constructor can no longer touch the property. A default counts as the initialising assignment, which happens before the constructor body runs. Any later write is a modification:

final readonly class Rule
{
    public string $className = SomeParser::class;

    public function __construct(string $className)
    {
        // Error: Cannot modify readonly property Rule::$className
        $this->className = $className;
    }
}

That is the single most likely way to trip over this feature: you add a default to an existing readonly property and break a constructor that was already assigning it.

unset() no longer works for lazy initialisation. Readonly properties without defaults can be unset before initialisation from the declaring scope, which is how the __get() lazy-init trick works. Give the property a default and that door closes:

final class LazyValue
{
    public readonly int $value = 1;

    public function __construct()
    {
        // Error: Cannot unset readonly property LazyValue::$value
        unset($this->value);
    }
}

If you rely on deferred initialisation with lazy objects, leave the default off.

It is not a constant — cloning can change it. This surprised me. A defaulted readonly property behaves like any other initialised readonly property during cloning, so __clone() and PHP 8.5's clone with may reinitialise it exactly once:

final class Counter
{
    public readonly int $value = 1;

    public function withValue(int $value): self
    {
        return clone($this, ['value' => $value]);
    }
}

$counter = new Counter();

var_dump($counter->value);               // int(1)
var_dump($counter->withValue(2)->value); // int(2)

So "typed per-instance constant" is a useful mental model for the constructor and nothing else.

Unserialisation can overwrite it. The property is initialised, so it is included in serialised output and can be restored from the payload — including a payload that carries a different value. unserialize('O:7:"Counter":1:{s:5:"value";i:2;}')->value gives int(2). Treat deserialised objects with the same suspicion you always should.

Trait composition is stricter than you'd guess. Two traits declaring the same readonly property compose fine if the defaults match, and fatal if they differ.

Static analysers need updating. The RFC calls this out directly: anything that currently flags a default on a readonly property as illegal has to be taught otherwise. Check your PHPStan, Psalm and IDE versions before assuming the red squiggle is real.

Wrapping Up#

If you maintain interface-driven class families — actions, ingestors, rules, migrations — go looking for constructors that exist only to assign fixed strings. Those are the ones 8.6 deletes. Everything else should stay on constructor promotion, because overridable values are a feature, not boilerplate.

PHP 8.6 lands on 19 November 2026, so this is a planning exercise rather than a today exercise. When you're ready, the Laravel upgrade checklist for PHP 8.6 covers the deprecations that will actually bite, and partial function application is the release's genuinely large change.

FAQ#

Can readonly properties have default values in PHP?

Yes, from PHP 8.6 onwards. In PHP 8.2 through 8.5 a default value on a readonly property is a compile-time error. The Readonly Property Defaults RFC removed that restriction, and the implementation is already merged into php-src ahead of the 19 November 2026 release.

What is the difference between a readonly property default and a class constant?

A class constant is static and accessed on the class itself, so it cannot satisfy an interface property declared as public string $name { get; }. A readonly property with a default is a per-instance property that can satisfy that contract. The other difference is mutability at the edges: a constant is truly fixed, whereas a defaulted readonly property can still be reinitialised once by __clone(), by clone with, or during unserialisation.

Can I override a readonly default in the constructor?

No. The default value counts as the initialising assignment and is applied before the constructor body runs, so assigning to the property in the constructor throws Cannot modify readonly property. If you need callers to be able to supply the value, use constructor property promotion with a parameter default instead of a property default.

When should I use readonly classes in Laravel?

Readonly classes suit value objects, DTOs, event payloads and configuration objects — anything that should be identical for its whole lifetime and safe to pass around without defensive copying. They are a poor fit for Eloquent models, anything Laravel hydrates in stages, and objects whose state legitimately changes. With 8.6 defaults, they also become a natural fit for fixed-metadata classes that implement a get-only interface.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.