Property Promotion And Constructor Shorthand
The Boilerplate It Replaces
The classic pattern — declare, accept, assign — for every dependency or value:
<?php
declare(strict_types=1);
// Pre-8.0 style: each property named three or four times.
class Point {
private float $x;
private float $y;
public function __construct(float $x, float $y) {
$this->x = $x;
$this->y = $y;
}
}
$p = new Point(1.5, 2.5);
echo 'created' . PHP_EOL;
// Prints:
// created
For a service with five injected dependencies, this is fifteen lines of pure ceremony that say nothing the parameter list did not already say.
Promotion: One Declaration Each
Put a visibility modifier (public, private, protected, or readonly) on a constructor parameter, and PHP automatically declares the property and assigns the argument to it. The two classes below are equivalent:
<?php
declare(strict_types=1);
class Point {
public function __construct(
private float $x,
private float $y,
) {}
}
$p = new Point(1.5, 2.5);
// A promoted parameter becomes a real property: declared, typed, and assigned.
echo 'x and y are now private properties of $p' . PHP_EOL;
// Prints:
// x and y are now private properties of $p
The visibility keyword is the trigger: a plain float $x parameter stays an ordinary argument, while private float $x is promoted to a property. Promoted properties keep their type, can have default values, and pair naturally with readonly for immutable value objects — public readonly float $x declares a public, read-once property in one stroke, which is the modern idiom for DTOs and value objects.
What It Expands To, And The Edges
Promotion is pure syntactic sugar: the compiler rewrites it into the declare-and-assign form above, so everything you know about ordinary properties still applies. That mental model resolves the edge cases:
- Mixing promoted and non-promoted is fine. Some parameters can be promoted while others stay plain arguments used only inside the constructor body.
- You can still have a constructor body. Promotion assigns before the body runs, so the body can validate or derive from the promoted properties:
if ($this->x < 0) throw ...works. - Defaults and nullability carry over, exactly as in a normal parameter and property.
- No promotion outside constructors. The feature is constructor-specific; other methods gain nothing from it.
- Combine with
readonlyfor immutability, and rememberreadonlymeans assign-once — the property is set during construction and cannot change afterward, which is what makes promoted value objects safe to pass around.
Because it desugars to the familiar form, promotion changes only how much you type, not how the object behaves — reflection, serialization, and inheritance all see ordinary properties. The property promotion and readonly lessons in the OOP track go deeper on the object-design implications; here the point is the mechanic and its equivalence.
What To Check
Before moving on, make sure you can:
- write a promoted constructor and state the declare/accept/assign it replaces
- explain that a visibility (or
readonly) modifier is what triggers promotion - mix promoted and non-promoted parameters, and keep a constructor body
- combine promotion with
readonlyfor immutable value objects - explain that promotion is sugar that desugars to ordinary properties
What You Should Be Able To Do
After this lesson, you should be able to use constructor property promotion to remove declaration boilerplate, reach for readonly promoted properties when building value objects and DTOs, and reason about promoted properties by mentally expanding them to their plain equivalent.
Practice
Practice: Convert To Promoted Constructor
Rewrite this class using constructor property promotion, keeping the validation. Then add an immutable value-object version of a Money class (amount and currency) using promoted readonly properties.
class HttpClient {
private string $baseUrl;
private int $timeout;
private array $headers;
public function __construct(string $baseUrl, int $timeout, array $headers) {
if ($timeout < 0) {
throw new InvalidArgumentException('timeout must be non-negative');
}
$this->baseUrl = $baseUrl;
$this->timeout = $timeout;
$this->headers = $headers;
}
}
Show solution
<?php
declare(strict_types=1);
class HttpClient {
public function __construct(
private string $baseUrl,
private int $timeout,
private array $headers,
) {
// Promotion assigns before the body runs, so validation on $this works.
if ($this->timeout < 0) {
throw new InvalidArgumentException('timeout must be non-negative');
}
}
}
// Immutable value object with promoted readonly properties:
final class Money {
public function __construct(
public readonly int $amount, // minor units
public readonly string $currency,
) {}
}
$m = new Money(1999, 'USD');
echo $m->amount . ' ' . $m->currency . PHP_EOL;
// $m->amount = 0; // would be a fatal error: readonly property
// Prints:
// 1999 USD
Two things to notice. In HttpClient, the three declare/assign pairs collapse to three promoted parameters, and the validation stays in the body — promotion assigns before the body runs, so $this->timeout is available to check. In Money, public readonly promotes and locks each property in one stroke: the value object is fully defined by its constructor signature, is immutable after construction, and needs no getters. This public readonly promotion is the modern idiom for DTOs and value objects.
Practice: Diagnose A Promotion Mistake
class Service {
public function __construct(string $name, private Logger $logger) {}
public function describe(): string {
return $this->name; // Error: undefined property Service::$name
}
}
Task
Explain why $name is not a property while $logger is, and fix it.
Show solution
Promotion is triggered only by a visibility (or readonly) modifier on the parameter. $logger has private, so it is promoted to a property. $name has no modifier — it is a plain constructor argument, live only inside the constructor body and gone afterward. So $this->name refers to a property that was never created, hence "undefined property," while $this->logger works.
This is the single most common promotion mistake: assuming every constructor parameter becomes a property. Only the ones with a visibility keyword do. The mixed signature here — one promoted, one not — is legal and sometimes intentional (a value you use only during construction should not be promoted), which is exactly why the compiler cannot warn you; it looks deliberate.
Fix: add a modifier to promote $name.
class Service {
public function __construct(
private string $name, // now promoted to a property
private Logger $logger,
) {}
public function describe(): string {
return $this->name; // works
}
}
The reliable mental check: scan the constructor parameters and mark which have public/private/protected/readonly. Those, and only those, exist as properties after construction. A bare string $name is an argument, not a property.
Practice: Define Promotion Review Checks
List what to check when reviewing code that uses (or should use) constructor property promotion.
Show solution
- Missing modifier where a property was intended. A parameter used as
$this->xin the constructor body or methods must carry a visibility/readonlymodifier, or it is not a property — the undefined-property bug. Check that every parameter the class treats as a property is actually promoted. - Unnecessary boilerplate that should be promoted. Legacy declare-accept-assign constructors are candidates for promotion; flag them for simplification, but only when it does not fight the surrounding pre-8.0 style pointlessly.
- Value objects and DTOs not using
readonly. A class whose job is to hold immutable data should promote withpublic readonly; a mutable promoted property in something meant to be a value object is a smell. - Validation placement. Constructor-body validation on promoted properties is fine (they are assigned before the body); confirm any invariant is actually enforced rather than assumed.
- Deliberate non-promotion is documented. A parameter intentionally left un-promoted (used only during construction) can look like the undefined-property bug to the next reader; a brief comment or clear usage prevents a "fix" that wrongly promotes it.
Static analysis catches undefined-property access and type mismatches; the review adds the design judgment — is this data immutable (readonly), and is every non-promotion intentional?