Articles
AI Article 19 min 697

PHP 8.4: Property Hooks, Fibers and Asymmetric Visibility

Deep dive into PHP 8.4 key features: property hooks replacing magic methods, asymmetric visibility, and mature Fibers. Benchmarks, Laravel and Symfony examples.

This article was generated by an AI model and may contain inaccuracies. Verify information before using in production.

What Changed in PHP 8.4 and Why It Matters

PHP 8.4 is not another minor release with a couple of syntax sugar additions. Three fundamental changes alter how you write code: property hooks, asymmetric visibility, and mature Fiber support. Plus a dozen smaller improvements that individually seem trivial but collectively eliminate entire categories of boilerplate.

I've worked with PHP since my first commercial projects — Laravel, plain PHP, some Symfony. Over the past two years my primary stack shifted toward Rails, but PHP projects haven't gone anywhere: two large Laravel monoliths in production, plus microservices. Both upgraded to 8.4 — here's what actually proved useful.

Property Hooks — Goodbye, Magic Getters

The Problem They Solve

Before 8.4, PHP had two ways to control property access: public fields (no control) and magic __get/__set (control, but no autocomplete, no typing, and useless stack traces).

// Old approach: __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}"),
        };
    }
}

Does it work? Yes. But the IDE doesn't know about the email property, PHPStan complains, and the stack trace on error points to __get, not the specific property. In a project with 200+ models this turns into a nightmare.

New Syntax

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 are native getters and setters built right into the property declaration. No magic, full typing, autocomplete, and proper stack traces.

Real Production Use Cases

1. Data normalization on write

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;
        }
    }

    // Computed property — no backing store
    public float $priceFormatted {
        get => $this->priceInCents / 100;
    }
}

Notice: $priceFormatted doesn't store a value. It's a pure computed property. Unlike __get, the IDE knows its type, PHPStan validates it, and accessing {{ $product->priceFormatted }} in templates works without additional accessors.

2. Lazy loading with caching

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
    {
        // Heavy query to ClickHouse
        return DB::connection('clickhouse')
            ->select('SELECT sum(amount), count(*) FROM orders WHERE report_id = ?', [$this->id]);
    }
}

3. Validation with domain rules

class Employee
{
    public string $taxId {
        set(string $value) {
            // INN: 10 or 12 digits
            if (!preg_match('/^\d{10}(\d{2})?$/', $value)) {
                throw new \DomainException('Invalid tax ID 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 — Comparison

Criterion Property Hooks get/set
IDE Autocomplete Full Requires @property PHPDoc
Static Analysis PHPStan level 9 Partial, via plugins
Stack Trace Points to property Points to __get
Performance Native opcode Fallback through magic
Inheritance Override individual hooks Override entire __get
Interfaces Properties in interfaces Only via @property

Properties in Interfaces

This is a separate big topic. PHP 8.4 allows declaring properties in interfaces:

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,
    ) {}
}

// Now you can type-hint properly
function greet(HasFullName $entity): string
{
    return "Hello, {$entity->fullName}!";
}

Previously you needed a getFullName() method in the interface. Properties in interfaces aren't sugar — they're a new level of contracts.

Asymmetric Visibility — Control Writes Without Closing Reads

The Problem

Classic situation: a property needs to be readable from outside but writable only from inside the class. Before 8.4 — two options:

// Option 1: readonly (but can't change after constructor)
class User
{
    public function __construct(
        public readonly string $name,
    ) {}
}

// Option 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 is too strict — you can't change the value even inside the class after construction. Private + getter works but it's boilerplate for every property.

New Syntax

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
    {
        // Works — we're inside the class
        $this->name = $newName;
    }
}

$user = new User('Vadim', 'vadim@example.com');
echo $user->name; // Vadim — reading works
$user->name = 'Ivan'; // Error: Cannot modify private(set) property
$user->changeName('Ivan'); // OK — through method

Where This Is Actually Needed

1. DTOs and 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';
    }
}

// Outside — read only
$transfer = new MoneyTransfer(100.50, 'usd');
echo $transfer->amount; // 100.5
echo $transfer->currency; // USD
echo $transfer->status; // pending

// IDE and templates see all properties
// but modification only through business methods

2. Eloquent Models (Laravel)

class Order extends Model
{
    // Public read, write only through business logic
    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. Combining with 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) — When to Use Which

Scenario readonly private(set)
Value never changes after creation Yes Overkill
Value changes through methods No — impossible Yes
DTO from API/database Yes Yes
Eloquent/Doctrine models No — ORM mutates fields Yes
Event Sourcing state No Yes
Config / immutable settings Yes Overkill

Simple rule: if the value is set once — readonly. If it can change but only through business logic — private(set).

Fibers — Async Without Callback Hell

What Are Fibers

Fibers appeared in PHP 8.1, but in 8.4 they became truly mature. A Fiber is a cooperative coroutine: a function that can pause its execution and return control to the caller.

$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"

A Fiber pauses via Fiber::suspend(), returning a value to the outside. The calling code continues via resume(), passing data back into the fiber.

Fibers vs Generators

Generators (yield) are similar but limited:

// Generator — only yields values outward
function readLines(string $file): Generator
{
    $handle = fopen($file, 'r');
    while ($line = fgets($handle)) {
        yield trim($line);
    }
    fclose($handle);
}

// Fiber — full bidirectional communication
$fiber = new Fiber(function (): void {
    $url = Fiber::suspend(); // receive URL from outside
    $response = file_get_contents($url);
    $parsed = json_decode($response, true);
    Fiber::suspend($parsed); // send result outside
    // continue with new data...
});
Criterion Generator Fiber
Bidirectional data transfer Limited (send) Full
Nesting yield from Free
Stackful No Yes
Use case Iterators, lazy collections Async I/O, concurrency
Memory overhead ~400 bytes ~8 KB

Practical Example: Parallel HTTP Requests

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']));
            }

            // Suspend fiber — curl handle is ready, but request not executed yet
            Fiber::suspend($ch);

            // When fiber resumes, curl_multi already executed the request
            $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 = [];

        // Start all fibers — each creates a curl handle and suspends
        foreach ($this->fibers as $name => $fiber) {
            $ch = $fiber->start();
            curl_multi_add_handle($multiHandle, $ch);
            $handles[$name] = $ch;
        }

        // Execute all requests in parallel
        do {
            $status = curl_multi_exec($multiHandle, $active);
            curl_multi_select($multiHandle);
        } while ($active && $status === CURLM_OK);

        // Resume fibers — each processes its own result
        foreach ($this->fibers as $name => $fiber) {
            $this->results[$name] = $fiber->resume();
        }

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

// Usage
$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();
// All 3 requests executed in parallel, not sequentially

Fibers in Laravel / Symfony

Laravel: parallel data collection for dashboard

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: async message processing

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);

                // Stock check
                $stockCheck = $this->stockService->check($order->items);
                Fiber::suspend(); // yield processor

                // Shipping calculation
                $shipping = $this->shippingService->calculate($order);
                Fiber::suspend();

                // Send notification
                $this->notifier->send($order->user, new OrderConfirmation($order));

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

        // Start all fibers
        foreach ($fibers as $fiber) {
            $fiber->start();
        }

        // Round-robin resumption
        $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;
    }
}

When NOT to Use Fibers

Fibers aren't a silver bullet. They're unnecessary when:

  • Single HTTP request to an API — a plain Http::get() is simpler
  • CPU-bound tasks — Fibers don't create threads, they're cooperative. For CPU you need pcntl_fork or queues
  • Simple sequential logic — the overhead of creating a Fiber (~8KB) isn't justified
  • Everything is already queued — if logic is in a Job, Fibers inside that job are usually redundant

Fibers are useful when you have multiple I/O operations that can run in parallel: requests to multiple APIs, parallel database queries, sending notifications.

New Array Functions

PHP 8.4 added functions that have been missing for years.

arrayfind, arrayfind_key

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

// Before: array_filter + reset (or 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];

// Before: 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

Real Example: Cart Validation

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

        // Any items without a price
        $hasMissingPrice = array_any(
            $items,
            fn(CartItem $item) => $item->price === null
        );

        // All items in stock
        $allInStock = array_all(
            $items,
            fn(CartItem $item) => $item->stock > 0
        );

        // Find first age-restricted item
        $ageRestricted = array_find(
            $items,
            fn(CartItem $item) => $item->ageRestriction !== null
        );

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

New String Methods and Closure Improvements

mbtrim, mbltrim, mb_rtrim

Finally, native trim for multibyte strings:

$input = "\u{00A0}Hello, world!\u{00A0}"; // non-breaking spaces
echo trim($input); // "\u{00A0}Hello, world!\u{00A0}" — trim doesn't see NBSP
echo mb_trim($input); // "Hello, world!" — mb_trim handles it

In my projects this eliminated a dozen custom helpers like mb_trim($str, "\xC2\xA0").

Closure::fromCallable improvements

class EventDispatcher
{
    private array $listeners = [];

    public function on(string $event, callable $listener): void
    {
        // PHP 8.4: first-class callable syntax
        $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();

// All these variants work uniformly
$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 and Breaking Changes

What Breaks When Upgrading from 8.3

1. Implicit nullable typing removed

// PHP 8.3: worked (but deprecated)
function process(string $name = null): void {}

// PHP 8.4: error. Must be:
function process(?string $name = null): void {}

This is the most widespread breaking change. In a large Laravel project we had ~150 such places. Auto-fix:

# PHP CS Fixer rule
php-cs-fixer fix --rules=nullable_type_declaration_for_default_null_value src/

2. CURLOPT_BINARYTRANSFER removed

// Was
curl_setopt($ch, CURLOPT_BINARYTRANSFER, true);

// Now — just remove it, this option did nothing since PHP 5.1.2

3. sessionregister, sessionunregister — fully removed

If legacy code with these functions still exists somewhere — time to migrate to $_SESSION[].

4. DateTime deprecation notices

// Deprecated: creating DateTime without arguments in certain contexts
$date = new DateTime();
$date->modify('+1 day'); // mutates the object

// Recommended:
$date = new DateTimeImmutable();
$tomorrow = $date->modify('+1 day'); // new object

Full Migration Checklist from 8.3

# 1. Update dependencies
composer update --dry-run
# Check package compatibility with 8.4

# 2. Run Rector for automatic migration
composer require rector/rector --dev
vendor/bin/rector process --set php84
# Rector automatically fixes:
# - nullable type declarations
# - removed constants
# - deprecated function replacements

# 3. PHPStan at maximum level
vendor/bin/phpstan analyse -l 9 src/

# 4. Tests
php artisan test --parallel

Rector Rules for 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([
        // Add property hooks where __get/__set exist
        \Rector\Php84\Rector\Class_\PropertyHookFromMagicGetSetRector::class,
    ]);

Performance: Benchmarks

All benchmarks on the same machine (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 are ~2.7x faster than magic methods.
Difference from direct access — within noise (~5%).

arrayfind vs arrayfilter + reset

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

array_filter + reset:  0.0034s (processes ALL elements)
array_find:            0.000018s (stops at first match)

~190x faster for early exit scenarios.

Fiber Creation Overhead

Benchmark: create + start + resume 100K fibers

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

Overhead per Fiber — ~2x over generator, ~8x over plain call.
But: this is creation overhead. For I/O-bound tasks (HTTP, DB) it's irrelevant.

Laravel and Symfony — Specific Adaptation Examples

Laravel: Request Validation with Property Hooks

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

    // Typed access to fields
    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'],
        ];
    }
}

// In controller:
class OrderController extends Controller
{
    public function store(CreateOrderRequest $request): JsonResponse
    {
        // Instead of $request->validated()['customer_email']
        $order = Order::create([
            'email' => $request->customerEmail,
            'items' => $request->items,
            'total' => $request->totalAmount,
        ]);

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

Laravel: Resource with 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 with Property Hooks for 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;
    }
}

Other Changes in PHP 8.4

new Without Parentheses in Method Chains

// PHP 8.3: parentheses required
$name = (new ReflectionClass($obj))->getName();
$result = (new Collection([1, 2, 3]))->map(fn($x) => $x * 2)->toArray();

// PHP 8.4: no parentheses
$name = new ReflectionClass($obj)->getName();
$result = new Collection([1, 2, 3])->map(fn($x) => $x * 2)->toArray();

Small thing, but in fluent API chains it removes visual noise.

#[\Deprecated] Attribute

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
    {
        // new implementation
    }
}

// Calling $gateway->pay(100) — E_USER_DEPRECATED with the message

Previously you needed trigger_error() inside the method or a @deprecated PHPDoc. The attribute is a native, machine-readable way.

New HTML5 Parser: \Dom\HTMLDocument

// PHP 8.3: DOMDocument with XML parser (breaks HTML5)
$doc = new DOMDocument();
@$doc->loadHTML($html); // @ to suppress warnings

// PHP 8.4: native HTML5 parser
$doc = \Dom\HTMLDocument::createFromString($html);
$doc = \Dom\HTMLDocument::createFromFile('page.html');

// Full HTML5 support: <template>, <slot>, <details>, inline SVG
// No warnings, no @ hacks

For HTML parsing in scrapers and tests — a massive step forward.

BCMath: Object 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: object 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')

For financial calculations where float arithmetic is unacceptable — finally a proper API.

php.ini Configuration: What to Check

; New directives in 8.4

; Property hooks use slightly more memory per class (negligible)
; If you have strict memory_limit — check after migration
memory_limit = 256M

; Fiber stack size (default 8KB, may need increase)
; Relevant if fibers call deep recursion
; fiber.stack_size = 8192

; opcache — property hooks create new opcodes
; Recommended to increase buffer if many classes with hooks
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000

Final Migration Checklist for PHP 8.4

  • [ ] Run rector process --set php84 — automatic syntax migration
  • [ ] Fix all function foo(Type $param = null)function foo(?Type $param = null)
  • [ ] Check composer update — all dependencies compatible with 8.4
  • [ ] Run PHPStan level 8+ — catch deprecated constructs
  • [ ] Run tests — especially where __get/__set are used
  • [ ] Replace DOMDocument::loadHTML() with \Dom\HTMLDocument for HTML5 parsing
  • [ ] Consider property hooks for Value Objects and DTOs — instead of boilerplate getters
  • [ ] Consider private(set) for Eloquent/Doctrine models — instead of readonly
  • [ ] Replace array_filter + reset with array_find where early exit applies
  • [ ] Check php.ini — opcache buffers, memory_limit
  • [ ] Update CI — PHP 8.4 image in GitHub Actions / GitLab CI
  • [ ] Update Docker image — php:8.4-fpm or php:8.4-cli
  • [ ] Post-deploy monitoring — error rate, memory usage, response time

Comments (0)