Objects Namespaces And Application Architecture
Property Promotion
Constructor property promotion lets you declare a constructor parameter and a class property in one place. It removes boilerplate when a constructor mainly stores incoming values.
Without promotion, the same name appears several times:
<?php
declare(strict_types=1);
final class UserProfile
{
public string $name;
public string $email;
public function __construct(string $name, string $email)
{
$this->name = $name;
$this->email = $email;
}
}
With property promotion, visibility on the constructor parameter creates the property automatically.
<?php
declare(strict_types=1);
final class UserProfile
{
public function __construct(
public string $name,
public string $email,
) {
}
}
$profile = new UserProfile('Ada', 'ada@example.com');
echo $profile->name . ' <' . $profile->email . '>' . PHP_EOL;
// Prints:
// Ada <ada@example.com>
The promoted properties are real properties. They have visibility, types, and optional default values.
Visibility Still Matters
Promotion works with public, protected, and private.
<?php
declare(strict_types=1);
final class RegisterUser
{
public function __construct(
private Mailer $mailer,
) {
}
}
interface Mailer
{
public function send(string $email, string $message): void;
}
For dependencies, private is usually appropriate because callers should not reach into the service and use its collaborators directly.
For simple data transfer objects or value objects, public readonly properties may be reasonable.
Validation Still Goes In The Constructor
Promotion does not remove the need for validation.
<?php
declare(strict_types=1);
final class EmailAddress
{
public function __construct(
public readonly string $value,
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Email address is not valid.');
}
}
}
$email = new EmailAddress('ada@example.com');
echo $email->value . PHP_EOL;
// Prints:
// ada@example.com
The assignment to the promoted property happens before the constructor body runs, but the constructor body can still validate and throw if the value is not acceptable.
Defaults
Promoted properties can have default values, just like normal constructor parameters.
<?php
declare(strict_types=1);
final class SearchOptions
{
public function __construct(
public readonly int $page = 1,
public readonly int $perPage = 20,
) {
if ($page < 1) {
throw new InvalidArgumentException('Page must be at least 1.');
}
if ($perPage < 1 || $perPage > 100) {
throw new InvalidArgumentException('Per-page must be between 1 and 100.');
}
}
}
$options = new SearchOptions(perPage: 50);
echo $options->page . ', ' . $options->perPage . PHP_EOL;
// Prints:
// 1, 50
Defaults should represent real defaults, not hide missing required data.
When Not To Promote
Promotion is best when storing constructor values is the main job. It can become harder to read when the constructor has many parameters, complex transformation, or properties that need different names from the input.
This can be clearer without promotion:
<?php
declare(strict_types=1);
final class NormalisedEmail
{
public readonly string $value;
public function __construct(string $email)
{
$email = strtolower(trim($email));
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Email address is not valid.');
}
$this->value = $email;
}
}
The property is not just storing the raw constructor parameter, so explicit assignment makes the transformation obvious.
What PHP Generates
PHP added constructor property promotion in PHP 8.0. A promoted parameter acts as both a constructor parameter and a declared property. Conceptually, this:
<?php
declare(strict_types=1);
final class Coordinate
{
public function __construct(
private int $x,
private int $y,
) {
}
public function label(): string
{
return $this->x . ',' . $this->y;
}
}
has the same storage intent as declaring $x and $y as private properties and assigning both parameters manually. Reflection still exposes real properties, and ReflectionProperty::isPromoted() can identify that they came from promotion.
The values are assigned before statements in the constructor body execute. That is why validation can read either $value or $this->value:
<?php
declare(strict_types=1);
final class Quantity
{
public function __construct(public readonly int $value)
{
if ($this->value < 1) {
throw new InvalidArgumentException('Quantity must be positive.');
}
}
}
Throwing from the constructor prevents the caller from receiving the invalid object. Promotion performs assignment; it does not make an invalid value valid.
Defaults Belong To The Parameter
A default on a promoted parameter is a constructor argument default. It is not a separate property default. The distinction matters to reflection and to understanding how the object is created.
<?php
declare(strict_types=1);
final class Pagination
{
public function __construct(
public readonly int $page = 1,
public readonly int $perPage = 20,
) {
if ($page < 1 || $perPage < 1 || $perPage > 100) {
throw new InvalidArgumentException('Pagination values are outside their limits.');
}
}
}
$pagination = new Pagination(perPage: 50);
echo $pagination->page . '/' . $pagination->perPage . PHP_EOL;
// Prints:
// 1/50
Use defaults only when omission has a clear meaning. A default empty string for a required customer name conceals missing data instead of modeling a real default.
Nullable promoted properties must use an explicit nullable type. A parameter written as public string $label = null is not a valid shortcut; write public ?string $label = null when null is genuinely allowed.
Mix Promoted And Ordinary Parameters
A constructor may combine promoted and ordinary parameters in any order. This is useful when a raw input is needed during creation but should not become a property.
<?php
declare(strict_types=1);
final class ApiCredential
{
private string $tokenHash;
public function __construct(
public readonly int $ownerId,
string $plainToken,
) {
if ($plainToken === '') {
throw new InvalidArgumentException('Token cannot be empty.');
}
$this->tokenHash = hash('sha256', $plainToken);
}
public function matches(string $plainToken): bool
{
return hash_equals($this->tokenHash, hash('sha256', $plainToken));
}
}
$credential = new ApiCredential(42, 'secret-token');
echo $credential->matches('secret-token') ? 'match' : 'no match';
echo PHP_EOL;
// Prints:
// match
$plainToken is constructor input, not durable public state. Promoting every parameter mechanically would store sensitive raw input that the object does not need afterward.
Promotion For Service Dependencies
Promotion is common in application services because dependencies are usually accepted and stored without transformation.
<?php
declare(strict_types=1);
interface OrderRepository
{
public function markPaid(int $orderId): void;
}
interface EventPublisher
{
public function publish(string $event): void;
}
final class MarkOrderPaid
{
public function __construct(
private OrderRepository $orders,
private EventPublisher $events,
) {
}
public function handle(int $orderId): void
{
$this->orders->markPaid($orderId);
$this->events->publish('order.paid:' . $orderId);
}
}
Private promotion communicates that collaborators are implementation details. Making every dependency public allows outside code to bypass the service and reach into its internals.
A constructor with ten promoted services is short on screen but still has ten dependencies. Promotion removes repeated syntax; it does not remove coupling. A long dependency list can indicate that the class owns too many responsibilities.
Language Restrictions
Promotion is available only on constructor parameters. It cannot be used on an ordinary method. A promoted parameter needs a property modifier such as public, protected, private, or readonly.
A class cannot separately declare a property with the same name as a promoted property. The promotion already creates it. Promoted parameters also cannot be variadic because one variadic parameter represents several arguments while a property stores one value.
PHP properties cannot use the callable type, so a promoted parameter cannot be typed callable either. Store a Closure when a callback must be property state, or use a focused interface when the callback represents a meaningful service.
Promotion works in constructors declared by normal classes, abstract classes, and traits. An interface may declare a constructor signature, but it has no property storage to promote. These restrictions follow from promotion being shorthand for both a parameter and a property.
Attributes And Reflection
An attribute placed on a promoted constructor argument is applied to both the generated property and the parameter. Frameworks using reflection may inspect one or both locations, so check that framework's documented behavior rather than assuming which target it reads.
Defaults behave differently: the default belongs to the parameter and is not copied as a property default. Reflection can reveal these details, but ordinary application code should normally treat promotion as readable source syntax rather than build logic around how it is expanded.
Named Arguments Make Parameter Names Public
Constructor parameters can be called by name:
<?php
declare(strict_types=1);
final class ExportOptions
{
public function __construct(
public readonly string $format,
public readonly bool $includeHeader = true,
) {
}
}
$options = new ExportOptions(format: 'csv', includeHeader: false);
echo $options->format . PHP_EOL;
Once callers use named arguments, renaming a promoted parameter can break them even when the generated property's meaning appears unchanged. For public packages and framework-facing DTOs, treat parameter names as part of the practical API.
Promotion does not change constructor calls by itself, but refactoring an old constructor to promotion deserves care. Keep the parameter order, names, types, defaults, and visibility behavior aligned unless the change is intentionally breaking.
Inheritance And Parent Constructors
If a child class declares its own constructor, PHP does not call the parent constructor automatically. The child must call parent::__construct() when parent initialization is required.
<?php
declare(strict_types=1);
class Message
{
public function __construct(protected string $body)
{
}
}
final class EmailMessage extends Message
{
public function __construct(
string $body,
private string $recipient,
) {
parent::__construct($body);
}
public function summary(): string
{
return $this->recipient . ': ' . $this->body;
}
}
Do not redeclare the same promoted property in both parent and child. Decide which class owns the state. Often composition is clearer than adding constructor complexity to a deep inheritance tree.
Promotion With Readonly And Property Hooks
readonly works naturally with promotion for values that should be assigned during construction and never reassigned. A whole readonly class can promote all of its constructor state without repeating readonly on each property.
PHP 8.4 property hooks can also be used with promoted properties. Hooks are useful when access needs a small local rule, but they increase the amount of behavior hidden in a compact declaration. The dedicated lessons on readonly classes and property hooks cover their exact semantics and design limits.
Do not stack every modern modifier onto a constructor because it is syntactically possible. Choose promotion, readonly, attributes, and hooks independently based on the object's contract.
When Explicit Properties Are Clearer
Promotion is poor when constructor input differs from stored state. Normalising an email, splitting a full name, converting currency units, hashing a token, or building a collection may be easier to follow with an explicit property and assignment.
<?php
declare(strict_types=1);
final class NormalisedEmail
{
public readonly string $value;
public function __construct(string $email)
{
$normalised = strtolower(trim($email));
if (!filter_var($normalised, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Email address is not valid.');
}
$this->value = $normalised;
}
}
Here $email is raw input and $value is validated, normalized state. Giving them different names and showing the assignment makes the transformation obvious.
Also prefer explicit declarations when promotion creates a constructor too dense to scan. Readability is the reason for the feature; shorter code that hides decisions has missed that purpose.
What You Should Be Able To Do
After this lesson, you should be able to convert repetitive constructor assignment into promotion, choose public, protected, private, or readonly deliberately, and explain that promoted values are assigned before the constructor body runs.
You should also understand parameter defaults, mixed promoted and ordinary inputs, restrictions such as variadic and callable properties, parent-constructor responsibilities, named-argument compatibility, and the cases where explicit properties make transformation or ownership clearer.
Practice
Practice: Use Property Promotion In A Value Object
Create a small value object using constructor property promotion.
Task
Build an EmailAddress class that:
- promotes a public readonly
string $value - validates the email address in the constructor body
- throws a clear exception for invalid email addresses
Use strict types. Keep the expected output in the PHP code block as printed lines or comments.
Check Your Work
Run cases for:
- a valid email address
- an invalid email address
Afterward, explain why promotion does not replace validation.
Show solution
This solution uses promotion to declare and assign the property, then validates the promoted value in the constructor body.
<?php
declare(strict_types=1);
final class EmailAddress
{
public function __construct(
public readonly string $value,
) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Email address is not valid.');
}
}
}
$email = new EmailAddress('ada@example.com');
echo $email->value . PHP_EOL;
try {
new EmailAddress('not-an-email');
} catch (InvalidArgumentException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// ada@example.com
// Email address is not valid.
Promotion removes repeated property assignment code, but it does not decide whether a value is valid. The constructor still owns that rule.
Practice: Promote Service Dependencies
Refactor a service constructor that manually stores two collaborators.
Task
Create AuditLog and UserRepository interfaces. Build a DeactivateUser service with both dependencies promoted as private properties. Its handle(int $userId) method should deactivate the user and then record an audit message.
Provide in-memory implementations that print each operation, call the service once, and include expected output comments.
Check Your Work
Confirm that promotion removed only declaration and assignment boilerplate. Explain why the dependencies remain private and why promotion does not reduce the service's actual dependency count.
Show solution
The constructor declares and stores both collaborators while the service keeps control of how they are used.
<?php
declare(strict_types=1);
interface AuditLog
{
public function record(string $message): void;
}
interface UserRepository
{
public function deactivate(int $userId): void;
}
final class EchoAuditLog implements AuditLog
{
public function record(string $message): void
{
echo 'audit: ' . $message . PHP_EOL;
}
}
final class MemoryUserRepository implements UserRepository
{
public function deactivate(int $userId): void
{
echo 'deactivated user ' . $userId . PHP_EOL;
}
}
final class DeactivateUser
{
public function __construct(
private UserRepository $users,
private AuditLog $audit,
) {
}
public function handle(int $userId): void
{
$this->users->deactivate($userId);
$this->audit->record('user.deactivated:' . $userId);
}
}
$service = new DeactivateUser(
new MemoryUserRepository(),
new EchoAuditLog(),
);
$service->handle(42);
// Prints:
// deactivated user 42
// audit: user.deactivated:42
Private visibility prevents callers from reaching through the service to its collaborators. Promotion shortens the constructor, but the class still has two real dependencies.
Practice: Choose Explicit Normalisation
Implement a class where promoting the raw constructor parameter would obscure the stored value.
Task
Create a ProductCode class that accepts a raw string, trims it, converts it to uppercase, validates it against the pattern AAA-999, and stores the result in a public readonly $value property.
Do not promote the raw parameter. Print one valid normalized code and catch one invalid code with expected output comments.
Check Your Work
Explain why $rawCode and $value represent different stages of the data and why an explicit property assignment makes that transformation easier to review.
Show solution
The constructor input is raw text, while the property stores the normalized and validated domain value.
<?php
declare(strict_types=1);
final class ProductCode
{
public readonly string $value;
public function __construct(string $rawCode)
{
$normalised = strtoupper(trim($rawCode));
if (preg_match('/^[A-Z]{3}-[0-9]{3}$/', $normalised) !== 1) {
throw new InvalidArgumentException('Product code must use AAA-999 format.');
}
$this->value = $normalised;
}
}
$code = new ProductCode(' kbD-104 ');
echo $code->value . PHP_EOL;
try {
new ProductCode('wrong');
} catch (InvalidArgumentException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
// Prints:
// KBD-104
// Product code must use AAA-999 format.
Promoting $rawCode would store the unprocessed input under the wrong meaning. The explicit $value property shows exactly where validated state begins.