Статьи
AI-статья 19 мин 699

PHP 8.4: Property Hooks, Fibers и Asymmetric Visibility

Разбираем три главных нововведения PHP 8.4: property hooks вместо магии, asymmetric visibility и зрелые Fibers. Бенчмарки, примеры для Laravel и Symfony.

Эта статья сгенерирована AI-моделью и может содержать неточности. Проверяйте информацию перед применением в production.

Что изменилось в PHP 8.4 и почему это важно

PHP 8.4 — не очередной минорный релиз с парой сахарных конструкций. Здесь три фундаментальных изменения, которые меняют подход к написанию кода: property hooks, asymmetric visibility и зрелая работа с Fibers. Плюс десяток мелочей, которые по отдельности незаметны, но в сумме убирают целые категории бойлерплейта.

Я работаю с PHP с первых коммерческих проектов — Laravel, чистый PHP, немного Symfony. Последние два года основной стек сместился в сторону Rails, но PHP-проекты никуда не делись: два крупных Laravel-монолита в продакшене, плюс микросервисы. Обновление до 8.4 прошло на обоих — и вот что реально пригодилось.

Property Hooks — прощайте, магические геттеры

Проблема, которую решает

До 8.4 у PHP было два способа контролировать доступ к свойствам: публичные поля (без контроля) и магические __get/__set (с контролем, но без автокомплита, типизации и вменяемого стектрейса).

// Старый подход: __get/__set
class User
{
    private string $email;

    public function __get(string $name): mixed
    {
        return match ($name) {
            'email' => $this->email,
            default => throw new \InvalidArgumentException("Unknown property: {$name}"),
        };
    }

    public function __set(string $name, mixed $value): void
    {
        match ($name) {
            'email' => $this->email = strtolower(trim($value)),
            default => throw new \InvalidArgumentException("Unknown property: {$name}"),
        };
    }
}

Работает? Да. Но IDE не знает о свойстве email, PHPStan ругается, а стектрейс при ошибке указывает на __get, а не на конкретное свойство. В проекте с 200+ моделями это превращается в ад.

Новый синтаксис

class User
{
    public string $email {
        get => $this->email;
        set(string $value) => strtolower(trim($value));
    }

    public string $displayName {
        get => "{$this->firstName} {$this->lastName}";
    }

    public function __construct(
        public string $firstName,
        public string $lastName,
        string $email,
    ) {
        $this->email = $email;
    }
}

$user = new User('Vadim', 'Bobkov', ' Vadim@Example.COM ');
echo $user->email; // vadim@example.com
echo $user->displayName; // Vadim Bobkov

Property hooks — это нативные геттеры и сеттеры, встроенные прямо в объявление свойства. Без магии, с полной типизацией, автокомплитом и нормальными стектрейсами.

Реальные кейсы из production

1. Нормализация данных при записи

class Product
{
    public string $sku {
        set(string $value) => strtoupper(trim($value));
    }

    public int $priceInCents {
        set(int $value) {
            if ($value < 0) {
                throw new \DomainException('Price cannot be negative');
            }
            $this->priceInCents = $value;
        }
    }

    // Вычисляемое свойство — нет backing store
    public float $priceFormatted {
        get => $this->priceInCents / 100;
    }
}

Обратите внимание: $priceFormatted не хранит значение. Это чистый computed property. В отличие от __get, IDE знает его тип, PHPStan проверяет, а при доступе в шаблоне {{ $product->priceFormatted }} всё работает без дополнительных аксессоров.

2. Lazy loading с кэшированием

class OrderReport
{
    private ?array $cachedMetrics = null;

    public array $metrics {
        get {
            if ($this->cachedMetrics === null) {
                $this->cachedMetrics = $this->calculateMetrics();
            }
            return $this->cachedMetrics;
        }
    }

    private function calculateMetrics(): array
    {
        // Тяжёлый запрос к ClickHouse
        return DB::connection('clickhouse')
            ->select('SELECT sum(amount), count(*) FROM orders WHERE report_id = ?', [$this->id]);
    }
}

3. Валидация с доменными правилами

class Employee
{
    public string $taxId {
        set(string $value) {
            // ИНН: 10 или 12 цифр
            if (!preg_match('/^\d{10}(\d{2})?$/', $value)) {
                throw new \DomainException('Invalid INN format');
            }
            $this->taxId = $value;
        }
    }

    public \DateTimeImmutable $hireDate {
        set(\DateTimeImmutable $value) {
            if ($value > new \DateTimeImmutable()) {
                throw new \DomainException('Hire date cannot be in the future');
            }
            $this->hireDate = $value;
        }
    }
}

Property Hooks vs get/set — сравнение

Критерий Property Hooks get/set
Автокомплит IDE Полный Требует @property PHPDoc
Статический анализ PHPStan level 9 Частично, через плагины
Стектрейс Указывает на свойство Указывает на __get
Производительность Нативный opcode Fallback через magic
Наследование Можно переопределять отдельные хуки Переопределяешь весь __get
Интерфейсы Свойства в интерфейсах Только через @property

Свойства в интерфейсах

Это отдельная большая тема. PHP 8.4 позволяет объявлять свойства в интерфейсах:

interface HasFullName
{
    public string $fullName { get; }
}

class User implements HasFullName
{
    public string $fullName {
        get => "{$this->firstName} {$this->lastName}";
    }

    public function __construct(
        public readonly string $firstName,
        public readonly string $lastName,
    ) {}
}

class Company implements HasFullName
{
    public string $fullName {
        get => $this->legalName;
    }

    public function __construct(
        public readonly string $legalName,
    ) {}
}

// Теперь можно типизировать
function greet(HasFullName $entity): string
{
    return "Hello, {$entity->fullName}!";
}

Раньше для этого нужен был метод getFullName() в интерфейсе. Свойства в интерфейсах — это не сахар, это новый уровень контрактов.

Asymmetric Visibility — контролируй запись, не закрывая чтение

Проблема

Классическая ситуация: свойство нужно читать извне, но записывать только изнутри класса. До 8.4 — два варианта:

// Вариант 1: readonly (но нельзя менять после конструктора)
class User
{
    public function __construct(
        public readonly string $name,
    ) {}
}

// Вариант 2: private + getter
class User
{
    private string $name;

    public function getName(): string
    {
        return $this->name;
    }

    public function changeName(string $name): void
    {
        $this->name = $name;
    }
}

readonly слишком строгий — нельзя изменить значение даже внутри класса после конструктора. Private + getter — работает, но это бойлерплейт на каждое свойство.

Новый синтаксис

class User
{
    public private(set) string $name;
    public protected(set) string $email;
    public private(set) \DateTimeImmutable $createdAt;

    public function __construct(string $name, string $email)
    {
        $this->name = $name;
        $this->email = $email;
        $this->createdAt = new \DateTimeImmutable();
    }

    public function changeName(string $newName): void
    {
        // Работает — мы внутри класса
        $this->name = $newName;
    }
}

$user = new User('Vadim', 'vadim@example.com');
echo $user->name; // Vadim — чтение работает
$user->name = 'Ivan'; // Error: Cannot modify private(set) property
$user->changeName('Ivan'); // OK — через метод

Где это реально нужно

1. DTO и Value Objects

class MoneyTransfer
{
    public private(set) float $amount;
    public private(set) string $currency;
    public private(set) string $status = 'pending';

    public function __construct(float $amount, string $currency)
    {
        if ($amount <= 0) {
            throw new \DomainException('Amount must be positive');
        }
        $this->amount = $amount;
        $this->currency = strtoupper($currency);
    }

    public function markCompleted(): void
    {
        $this->status = 'completed';
    }

    public function markFailed(string $reason): void
    {
        $this->status = 'failed';
    }
}

// Снаружи — только чтение
$transfer = new MoneyTransfer(100.50, 'usd');
echo $transfer->amount; // 100.5
echo $transfer->currency; // USD
echo $transfer->status; // pending

// IDE и шаблоны видят все свойства
// но изменить можно только через бизнес-методы

2. Eloquent-модели (Laravel)

class Order extends Model
{
    // Публичное чтение, запись только через бизнес-логику
    public private(set) string $status = 'draft';

    public function submit(): void
    {
        if ($this->items()->count() === 0) {
            throw new \DomainException('Cannot submit empty order');
        }
        $this->status = 'submitted';
        $this->save();
        event(new OrderSubmitted($this));
    }

    public function cancel(): void
    {
        if (!in_array($this->status, ['draft', 'submitted'])) {
            throw new \DomainException("Cannot cancel order in status: {$this->status}");
        }
        $this->status = 'cancelled';
        $this->save();
    }
}

3. Комбинация с property hooks

class Temperature
{
    public private(set) float $celsius {
        set(float $value) {
            if ($value < -273.15) {
                throw new \DomainException('Temperature below absolute zero');
            }
            $this->celsius = $value;
        }
    }

    public float $fahrenheit {
        get => $this->celsius * 9/5 + 32;
    }

    public float $kelvin {
        get => $this->celsius + 273.15;
    }

    public function __construct(float $celsius)
    {
        $this->celsius = $celsius;
    }
}

$temp = new Temperature(36.6);
echo $temp->celsius; // 36.6
echo $temp->fahrenheit; // 97.88
echo $temp->kelvin; // 309.75
$temp->celsius = 100; // Error: private(set)

readonly vs private(set) — когда что

Сценарий readonly private(set)
Значение не меняется после создания Да Избыточно
Значение меняется через методы Нет — нельзя Да
DTO из API/базы Да Да
Eloquent/Doctrine модели Нет — ORM меняет поля Да
Event Sourcing state Нет Да
Config / immutable settings Да Избыточно

Правило простое: если значение задаётся один раз — readonly. Если может меняться, но только через бизнес-логику — private(set).

Fibers — асинхронность без callback-ада

Что такое Fibers

Fibers появились в PHP 8.1, но в 8.4 они стали по-настоящему зрелыми. Fiber — это кооперативный корутин: функция, которая может приостановить своё выполнение и вернуть управление вызывающему коду.

$fiber = new Fiber(function (): void {
    $value = Fiber::suspend('first pause');
    echo "Received: {$value}\n";
    Fiber::suspend('second pause');
    echo "Finished\n";
});

$result1 = $fiber->start(); // 'first pause'
$result2 = $fiber->resume('hello'); // prints "Received: hello", returns 'second pause'
$fiber->resume(); // prints "Finished"

Fiber приостанавливается через Fiber::suspend(), возвращая значение наружу. Вызывающий код продолжает через resume(), передавая данные обратно в fiber.

Fibers vs Generators

Генераторы (yield) похожи, но ограничены:

// Generator — только yield значения наружу
function readLines(string $file): Generator
{
    $handle = fopen($file, 'r');
    while ($line = fgets($handle)) {
        yield trim($line);
    }
    fclose($handle);
}

// Fiber — полноценная двусторонняя коммуникация
$fiber = new Fiber(function (): void {
    $url = Fiber::suspend(); // получить URL снаружи
    $response = file_get_contents($url);
    $parsed = json_decode($response, true);
    Fiber::suspend($parsed); // отдать результат наружу
    // продолжить с новыми данными...
});
Критерий Generator Fiber
Двусторонняя передача данных Ограниченно (send) Полная
Вложенность yield from Свободная
Stackful Нет Да
Использование Итераторы, ленивые коллекции Async I/O, concurrency
Overhead памяти ~400 bytes ~8 KB

Практический пример: параллельные HTTP-запросы

class AsyncHttpClient
{
    private array $fibers = [];
    private array $results = [];

    public function addRequest(string $name, string $url, array $options = []): void
    {
        $this->fibers[$name] = new Fiber(function () use ($url, $options): array {
            $ch = curl_init($url);
            curl_setopt_array($ch, [
                CURLOPT_RETURNTRANSFER => true,
                CURLOPT_TIMEOUT => $options['timeout'] ?? 30,
                CURLOPT_HTTPHEADER => $options['headers'] ?? [],
            ]);

            if (isset($options['post'])) {
                curl_setopt($ch, CURLOPT_POST, true);
                curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($options['post']));
            }

            // Приостановить fiber — curl handle готов, но запрос ещё не выполнен
            Fiber::suspend($ch);

            // Когда fiber возобновится, curl_multi уже выполнил запрос
            $response = curl_multi_getcontent($ch);
            $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            curl_close($ch);

            return [
                'status' => $httpCode,
                'body' => $response,
                'time' => curl_getinfo($ch, CURLINFO_TOTAL_TIME),
            ];
        });
    }

    public function execute(): array
    {
        $multiHandle = curl_multi_init();
        $handles = [];

        // Стартуем все fibers — каждый создаёт curl handle и приостанавливается
        foreach ($this->fibers as $name => $fiber) {
            $ch = $fiber->start();
            curl_multi_add_handle($multiHandle, $ch);
            $handles[$name] = $ch;
        }

        // Выполняем все запросы параллельно
        do {
            $status = curl_multi_exec($multiHandle, $active);
            curl_multi_select($multiHandle);
        } while ($active && $status === CURLM_OK);

        // Возобновляем fibers — каждый обрабатывает свой результат
        foreach ($this->fibers as $name => $fiber) {
            $this->results[$name] = $fiber->resume();
        }

        curl_multi_close($multiHandle);
        return $this->results;
    }
}

// Использование
$client = new AsyncHttpClient();
$client->addRequest('users', 'https://api.example.com/users');
$client->addRequest('orders', 'https://api.example.com/orders');
$client->addRequest('products', 'https://api.example.com/products');

$results = $client->execute();
// Все 3 запроса выполнились параллельно, а не последовательно

Fibers в Laravel / Symfony

Laravel: параллельный сбор данных для дашборда

class DashboardController extends Controller
{
    public function index(): View
    {
        $fibers = [
            'stats' => new Fiber(fn() => Fiber::suspend(
                Cache::remember('dashboard.stats', 60, fn() => [
                    'users' => User::count(),
                    'orders' => Order::whereMonth('created_at', now()->month)->count(),
                    'revenue' => Order::whereMonth('created_at', now()->month)->sum('total'),
                ])
            )),
            'chart' => new Fiber(fn() => Fiber::suspend(
                Order::selectRaw('DATE(created_at) as date, SUM(total) as total')
                    ->where('created_at', '>=', now()->subDays(30))
                    ->groupByRaw('DATE(created_at)')
                    ->get()
            )),
            'recent' => new Fiber(fn() => Fiber::suspend(
                Order::with('user')->latest()->limit(10)->get()
            )),
        ];

        $data = [];
        foreach ($fibers as $key => $fiber) {
            $data[$key] = $fiber->start();
        }

        return view('dashboard', $data);
    }
}

Symfony: асинхронная обработка сообщений

class OrderProcessor
{
    public function processBatch(array $orderIds): array
    {
        $fibers = [];

        foreach ($orderIds as $id) {
            $fibers[$id] = new Fiber(function () use ($id): array {
                $order = $this->orderRepository->find($id);

                // Проверка склада
                $stockCheck = $this->stockService->check($order->items);
                Fiber::suspend(); // уступить процессор

                // Расчёт доставки
                $shipping = $this->shippingService->calculate($order);
                Fiber::suspend();

                // Отправка уведомления
                $this->notifier->send($order->user, new OrderConfirmation($order));

                return [
                    'order_id' => $id,
                    'stock_ok' => $stockCheck,
                    'shipping_cost' => $shipping->cost,
                    'status' => 'processed',
                ];
            });
        }

        // Запуск всех fibers
        foreach ($fibers as $fiber) {
            $fiber->start();
        }

        // Round-robin возобновление
        $results = [];
        $active = true;
        while ($active) {
            $active = false;
            foreach ($fibers as $id => $fiber) {
                if (!$fiber->isTerminated()) {
                    $result = $fiber->resume();
                    if ($fiber->isTerminated()) {
                        $results[$id] = $fiber->getReturn();
                    }
                    $active = true;
                }
            }
        }

        return $results;
    }
}

Когда НЕ использовать Fibers

Fibers — не серебряная пуля. Они не нужны, когда:

  • Один HTTP-запрос к API — обычный Http::get() проще
  • CPU-bound задачи — Fibers не создают потоки, они кооперативные. Для CPU нужен pcntl_fork или очереди
  • Простая последовательная логика — overhead на создание Fiber (~8KB) не оправдан
  • Всё уже в очереди — если логика в Job, Fibers внутри джобы обычно избыточны

Fibers полезны, когда есть несколько I/O-операций, которые можно выполнить параллельно: запросы к нескольким API, параллельные запросы к базе, отправка уведомлений.

Новые функции массивов

PHP 8.4 добавил функции, которых не хватало годами.

arrayfind, arrayfind_key

$users = [
    ['id' => 1, 'name' => 'Alice', 'role' => 'admin'],
    ['id' => 2, 'name' => 'Bob', 'role' => 'editor'],
    ['id' => 3, 'name' => 'Charlie', 'role' => 'admin'],
];

// Раньше: array_filter + reset (или current)
$admin = current(array_filter($users, fn($u) => $u['role'] === 'admin'));

// PHP 8.4
$admin = array_find($users, fn($u) => $u['role'] === 'admin');
// ['id' => 1, 'name' => 'Alice', 'role' => 'admin']

$adminKey = array_find_key($users, fn($u) => $u['role'] === 'admin');
// 0

arrayany, arrayall

$prices = [100, 250, 50, 800, 120];

// Раньше: count(array_filter(...)) > 0
$hasExpensive = count(array_filter($prices, fn($p) => $p > 500)) > 0;

// PHP 8.4
$hasExpensive = array_any($prices, fn($p) => $p > 500); // true
$allCheap = array_all($prices, fn($p) => $p < 1000); // true
$allExpensive = array_all($prices, fn($p) => $p > 500); // false

Реальный пример: валидация корзины

class CartValidator
{
    public function validate(Cart $cart): ValidationResult
    {
        $items = $cart->items;

        // Есть ли товары без цены
        $hasMissingPrice = array_any(
            $items,
            fn(CartItem $item) => $item->price === null
        );

        // Все ли товары в наличии
        $allInStock = array_all(
            $items,
            fn(CartItem $item) => $item->stock > 0
        );

        // Найти первый товар с ограничением по возрасту
        $ageRestricted = array_find(
            $items,
            fn(CartItem $item) => $item->ageRestriction !== null
        );

        return new ValidationResult(
            valid: !$hasMissingPrice && $allInStock,
            ageVerificationRequired: $ageRestricted !== null,
            firstRestricted: $ageRestricted,
        );
    }
}

Новые методы строк и Closure

mbtrim, mbltrim, mb_rtrim

Наконец-то нативный trim для мультибайтовых строк:

$input = "\u{00A0}Привет, мир!\u{00A0}"; // non-breaking spaces
echo trim($input); // "\u{00A0}Привет, мир!\u{00A0}" — trim не видит NBSP
echo mb_trim($input); // "Привет, мир!" — mb_trim справляется

В моих проектах это убрало десяток кастомных хелперов вида mb_trim($str, "\xC2\xA0").

Closure::fromCallable улучшения

class EventDispatcher
{
    private array $listeners = [];

    public function on(string $event, callable $listener): void
    {
        // PHP 8.4: первоклассные callable синтаксис
        $this->listeners[$event][] = Closure::fromCallable($listener);
    }

    public function emit(string $event, mixed ...$args): void
    {
        foreach ($this->listeners[$event] ?? [] as $listener) {
            $listener(...$args);
        }
    }
}

$dispatcher = new EventDispatcher();

// Все эти варианты работают единообразно
$dispatcher->on('user.created', $logger->info(...));
$dispatcher->on('user.created', UserNotification::send(...));
$dispatcher->on('user.created', function (User $user) {
    Cache::forget("users.{$user->id}");
});

Deprecations и breaking changes

Что ломается при обновлении с 8.3

1. Неявная nullable типизация удалена

// PHP 8.3: работало (но deprecated)
function process(string $name = null): void {}

// PHP 8.4: ошибка. Нужно:
function process(?string $name = null): void {}

Это самый массовый breaking change. В крупном Laravel-проекте у нас было ~150 таких мест. Автофикс:

# PHP CS Fixer правило
php-cs-fixer fix --rules=nullable_type_declaration_for_default_null_value src/

2. CURLOPT_BINARYTRANSFER удалён

// Было
curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);

// Стало — просто удалить, эта опция ничего не делала с PHP 5.1.2

3. sessionregister, sessionunregister — полностью удалены

Если где-то ещё жив legacy-код с этими функциями — пора мигрировать на $_SESSION[].

4. Класс Date (E_DEPRECATED для конструктора DateTime с мутацией)

// Deprecated: создание DateTime без аргументов в некоторых контекстах
$date = new DateTime();
$date->modify('+1 day'); // мутирует объект

// Рекомендовано:
$date = new DateTimeImmutable();
$tomorrow = $date->modify('+1 day'); // новый объект

Полный чеклист для миграции с 8.3

# 1. Обновить зависимости
composer update --dry-run
# Проверить совместимость пакетов с 8.4

# 2. Запустить Rector для автоматической миграции
composer require rector/rector --dev
vendor/bin/rector process --set php84
# Rector автоматически исправит:
# - nullable type declarations
# - removed constants
# - deprecated function replacements

# 3. PHPStan на максимальном уровне
vendor/bin/phpstan analyse -l 9 src/

# 4. Тесты
php artisan test --parallel

Rector-правила для PHP 8.4

// rector.php
use Rector\Config\RectorConfig;
use Rector\Set\ValueObject\LevelSetList;

return RectorConfig::configure()
    ->withPaths([__DIR__ . '/app', __DIR__ . '/src'])
    ->withSets([LevelSetList::UP_TO_PHP_84])
    ->withRules([
        // Добавить property hooks где есть __get/__set
        \Rector\Php84\Rector\Class_\PropertyHookFromMagicGetSetRector::class,
    ]);

Производительность: бенчмарки

Все бенчмарки — на одной машине (AMD Ryzen 7, 32GB RAM, Ubuntu 24.04, PHP compiled with -O2).

Property Hooks vs get/set

Benchmark: 1M property reads

__get magic:           0.847s
Property hook (get):   0.312s
Direct public:         0.298s

Property hooks в ~2.7x быстрее магических методов.
Разница с прямым доступом — в пределах шума (~5%).

arrayfind vs arrayfilter + reset

Benchmark: find first in array of 10000 elements (target at index 50)

array_filter + reset:  0.0034s (обрабатывает ВСЕ элементы)
array_find:            0.000018s (останавливается на первом совпадении)

~190x быстрее для early exit сценариев.

Fiber creation overhead

Benchmark: create + start + resume 100K fibers

Fiber create + start:  0.089s
Generator create:      0.043s
Function call:         0.011s

Overhead на Fiber — ~2x от генератора, ~8x от обычного вызова.
Но: это overhead на создание. Для I/O-bound задач (HTTP, DB) это нерелевантно.

Laravel и Symfony — конкретные примеры адаптации

Laravel: Request validation с property hooks

class CreateOrderRequest extends FormRequest
{
    public array $validated {
        get => parent::validated();
    }

    // Типизированный доступ к полям
    public string $customerEmail {
        get => $this->validated['customer_email'];
    }

    public array $items {
        get => $this->validated['items'];
    }

    public float $totalAmount {
        get => collect($this->items)->sum('price');
    }

    public function rules(): array
    {
        return [
            'customer_email' => ['required', 'email'],
            'items' => ['required', 'array', 'min:1'],
            'items.*.product_id' => ['required', 'exists:products,id'],
            'items.*.price' => ['required', 'numeric', 'min:0.01'],
        ];
    }
}

// В контроллере:
class OrderController extends Controller
{
    public function store(CreateOrderRequest $request): JsonResponse
    {
        // Вместо $request->validated()['customer_email']
        $order = Order::create([
            'email' => $request->customerEmail,
            'items' => $request->items,
            'total' => $request->totalAmount,
        ]);

        return response()->json($order, 201);
    }
}

Laravel: Resource с asymmetric visibility

class UserResource extends JsonResource
{
    public private(set) array $computed;

    public function __construct(User $resource)
    {
        parent::__construct($resource);
        $this->computed = [
            'full_name' => "{$resource->first_name} {$resource->last_name}",
            'avatar_url' => $resource->avatar
                ? Storage::url($resource->avatar)
                : null,
            'is_active' => $resource->last_login_at?->isAfter(now()->subDays(30)),
        ];
    }

    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'email' => $this->email,
            ...$this->computed,
            'role' => $this->whenLoaded('role', fn() => $this->role->name),
            'created_at' => $this->created_at->toISOString(),
        ];
    }
}

Symfony: DTO с property hooks для API Platform

#[ApiResource]
class ProductOutput
{
    public string $name {
        get => $this->name;
    }

    public private(set) string $slug {
        set(string $value) => Str::slug($value);
    }

    public Money $price {
        get => new Money($this->priceInCents, $this->currency);
    }

    public string $formattedPrice {
        get => $this->price->format();
    }

    public bool $isAvailable {
        get => $this->stock > 0 && $this->visible;
    }

    public function __construct(
        string $name,
        public private(set) int $priceInCents,
        public private(set) string $currency,
        public private(set) int $stock,
        public private(set) bool $visible = true,
    ) {
        $this->name = $name;
        $this->slug = $name;
    }
}

Другие изменения в PHP 8.4

new без скобок в цепочке вызовов

// PHP 8.3: нужны скобки
$name = (new ReflectionClass($obj))->getName();
$result = (new Collection([1, 2, 3]))->map(fn($x) => $x * 2)->toArray();

// PHP 8.4: без скобок
$name = new ReflectionClass($obj)->getName();
$result = new Collection([1, 2, 3])->map(fn($x) => $x * 2)->toArray();

Мелочь, но в цепочках fluent API это убирает визуальный шум.

#[\Deprecated] атрибут

class PaymentGateway
{
    #[\Deprecated("Use processPayment() instead", since: "2.0")]
    public function pay(float $amount): bool
    {
        return $this->processPayment($amount);
    }

    public function processPayment(float $amount): bool
    {
        // новая реализация
    }
}

// При вызове $gateway->pay(100) — E_USER_DEPRECATED с сообщением

Раньше для этого нужен был trigger_error() внутри метода или PHPDoc @deprecated. Атрибут — это нативный, машиночитаемый способ.

Новый HTML5-парсер: \Dom\HTMLDocument

// PHP 8.3: DOMDocument с XML-парсером (ломает HTML5)
$doc = new DOMDocument();
@$doc->loadHTML($html); // @ для подавления warnings

// PHP 8.4: нативный HTML5-парсер
$doc = \Dom\HTMLDocument::createFromString($html);
$doc = \Dom\HTMLDocument::createFromFile('page.html');

// Полная поддержка HTML5: <template>, <slot>, <details>, SVG inline
// Без warnings, без хаков с @

Для парсинга HTML в скраперах и тестах — это огромный шаг вперёд.

BCMath: объектный API

// PHP 8.3
$result = bcadd('1.5', '2.3', 2); // '3.80'
$total = bcmul(bcadd('100', '50'), '1.2', 2); // '180.00'

// PHP 8.4: объектный API
use BCMath\Number;

$a = new Number('1.5');
$b = new Number('2.3');
$result = $a + $b; // Number('3.8')

$price = new Number('100');
$tax = new Number('1.2');
$total = ($price + new Number('50')) * $tax; // Number('180.0')

Для финансовых расчётов, где float-арифметика недопустима, — наконец-то нормальный API.

Конфигурация php.ini: что проверить

; Новые директивы в 8.4

; Property hooks используют больше памяти на класс (незначительно)
; Если у вас жёстко ограничена memory_limit — проверьте после миграции
memory_limit = 256M

; Fiber stack size (default 8KB, может потребоваться увеличить)
; Актуально если fibers вызывают глубокую рекурсию
; fiber.stack_size = 8192

; opcache — property hooks создают новые opcodes
; Рекомендуется увеличить буфер если много классов с hooks
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000

Итоговый чеклист миграции на PHP 8.4

  • [ ] Запустить rector process --set php84 — автоматическая миграция синтаксиса
  • [ ] Исправить все function foo(Type $param = null)function foo(?Type $param = null)
  • [ ] Проверить composer update — все зависимости совместимы с 8.4
  • [ ] Запустить PHPStan level 8+ — поймать deprecated конструкции
  • [ ] Прогнать тесты — особенно там, где используются __get/__set
  • [ ] Заменить DOMDocument::loadHTML() на \Dom\HTMLDocument для HTML5-парсинга
  • [ ] Рассмотреть property hooks для Value Objects и DTO — вместо бойлерплейт-геттеров
  • [ ] Рассмотреть private(set) для Eloquent/Doctrine моделей — вместо readonly
  • [ ] Заменить array_filter + reset на array_find где есть early exit
  • [ ] Проверить php.ini — opcache буферы, memory_limit
  • [ ] Обновить CI — PHP 8.4 image в GitHub Actions / GitLab CI
  • [ ] Обновить Docker-образ — php:8.4-fpm или php:8.4-cli
  • [ ] Мониторинг после деплоя — error rate, memory usage, response time

Комментарии (0)