Обидві фічі приїхали в PHP 8.0, але лікують різні болі. Constructor property promotion прибирає дублювання в оголошенні: замість трьох рядків на кожну залежність (властивість, параметр конструктора, присвоєння) модифікатор видимості просто ставиться перед параметром — public function __construct(private LoggerInterface $logger) {} — і PHP сам створює властивість із тим самим типом та імʼям. Named arguments лікують місце виклику: аргумент передається за іменем параметра, а не за позицією, тому необовʼязкові параметри можна пропускати вибірково, а не «дотягувати» дефолтами до потрібного. new SearchQuery(text: 'php', city: 'Львів') читається без заглядання в сигнатуру, тоді як new SearchQuery('php', 1, 20, 'Львів') — ні.
Механіка named arguments має кілька жорстких правил, і саме на них ловлять. Позиційні аргументи мають іти першими: f(text: 'php', 3) — це помилка парсингу, а не рантайму. Передати той самий параметр двічі (позиційно й за іменем) не можна — буде Error: Named parameter $x overwrites previous argument. Неіснуюче імʼя дає Error: Unknown named parameter $x. Іменовані аргументи, що не збіглися з жодним параметром, збираються у варіадик із рядковими ключами — саме тому прозорі декоратори виду handle(...$args) продовжують працювати. Розпакування масиву з рядковими ключами в іменовані аргументи (f(...['page' => 2])) дозволене з PHP 8.1; у 8.0 воно кидало Cannot unpack array with string keys. Окремо варто памʼятати, що func_get_args() бачить виклик так, ніби все передали позиційно: пропущені необовʼязкові параметри підставляються своїми дефолтами, тому старий код на func_num_args() починає рахувати інакше.
Головна ціна named arguments — імена параметрів стають публічним API. PHP звіряє сумісність сигнатур за типами й кількістю параметрів, але не за іменами: клас, що реалізує інтерфейс, може назвати параметр як завгодно і завантажиться без жодної помилки. Тому виклик $gateway->charge(amount: 100) через тип-інтерфейс впаде в рантаймі на тій єдиній реалізації, де параметр названо $sum. Так само перейменування $str на $string у власній бібліотеці — це BC break, навіть якщо тип і позиція незмінні. Практичне правило: іменовані аргументи безпечні для конструкторів конкретних класів, DTO, атрибутів і вбудованих функцій (їх імена якраз і причесали в PHP 8.0 саме заради цього), а для поліморфних викликів через абстракцію надійніше лишатися позиційним.
У промоції своя пастка, і вона про порядок. Присвоєння промотованих властивостей відбувається до тіла конструктора, тому тіло вже працює з $this->…. Для звичайних властивостей це нічого не змінює, а для readonly (PHP 8.1+) означає, що властивість уже ініціалізована, і $this->text = trim($text) у тілі дасть Cannot modify readonly property. Трансформацію значень доводиться виносити або на рівень виклику — приватний конструктор плюс статичний fromRequest(), який чистить дані, — або в окремі value-обʼєкти, що нормалізують себе самі. Валідація ж без зміни значення (кинути InvalidArgumentException, прочитавши $this->perPage) у тілі конструктора цілком легальна.
Межі промоції варто називати списком: тільки __construct не-абстрактного класу, не варіадичний параметр, не тип callable (замість нього — Closure), не можна дублювати ту саму властивість у тілі класу, модифікатор видимості обовʼязковий. readonly доступний з 8.1, асиметрична видимість public private(set) — з 8.4. Атрибут перед промотованим параметром вішається і на параметр, і на властивість, а ReflectionProperty::isPromoted() дозволяє відрізнити такі властивості в рантаймі. Останнє — документація типів: узагальнені типи промотованих властивостей описують @param list<string> $tags у docblock конструктора, бо окремого місця для @var більше немає; при міграції старих DTO на promotion це найчастіша тиха втрата типів для PHPStan.
final class SearchQuery
{
/** @param list<string> $tags */
public function __construct(
public readonly string $text,
public readonly int $page = 1,
public readonly int $perPage = 20,
public readonly ?string $city = null,
public readonly array $tags = [],
) {
// тіло виконується ПІСЛЯ присвоєння — властивості вже заповнені
if ($this->perPage > 100) {
throw new InvalidArgumentException('perPage максимум 100');
}
// $this->text = trim($text); // Error: readonly вже ініціалізовано промоцією
}
}
// називаємо лише те, що відрізняється від типового; page і perPage пропускаємо
$q = new SearchQuery(text: 'php', city: 'Львів', tags: ['remote']);
// new SearchQuery(text: 'php', 3); // Parse error: позиційний після іменованого
// new SearchQuery('php', text: 'x'); // Error: Named parameter $text overwrites previous argument
// розпакування масиву з РЯДКОВИМИ ключами як іменованих — PHP 8.1+
$filters = ['text' => 'php', 'perPage' => 50];
$q2 = new SearchQuery(...$filters);
// зайвий ключ ламає виклик, тому вхідні дані фільтруємо явно
// new SearchQuery(...['text' => 'php', 'sort' => 'new']); // Unknown named parameter $sort
function collect(...$args): array
{
return $args; // іменовані аргументи стають рядковими ключами
}
var_dump(collect(1, 2)); // [0 => 1, 1 => 2]
var_dump(collect(a: 1, b: 2)); // ['a' => 1, 'b' => 2]
Формула на дві фрази: «promotion скорочує оголошення, named arguments — виклик; разом вони роблять DTO читабельним без білдера». І одразу назвіть ціну: «але імена параметрів після цього — публічний API, перейменування = BC break».