PHP 8.4 wyszło oficjalnie 21 listopada 2024. Pisałem o RC w lipcu i wrześniu – teraz mam kilka dni z finalną wersją i pierwsze projekty które uruchamiam na 8.4. Property hooks, asymmetric visibility, Lazy Objects, BcMath\Number – wszystko wylądowało dokładnie jak RFC obiecywało. Czas na uczciwy bilans: co od razu wchodzi do kodu, co zaskakuje i jak gładka jest migracja z 8.3.
Property Hooks w produkcji – co poszło inaczej niż planowałem
<?php
declare(strict_types=1);
// Zakładałem że przepiszę wszystkie Value Objects na property hooks.
// W praktyce - tylko nowy kod. Migracja istniejącego to za duże ryzyko regresu.
// Nowy kod - przepisałem DTO importu produktów
readonly class ProductImportRow
{
public string $sku {
set(string $v) {
if (empty(trim($v))) {
throw new \InvalidArgumentException('SKU cannot be empty');
}
$this->sku = strtoupper(trim($v));
}
}
public float $price {
set(float $v) {
if ($v < 0) {
throw new \InvalidArgumentException("Price cannot be negative: {$v}");
}
$this->price = round($v, 2);
}
}
public string $status {
get => match($this->rawStatus) {
'1', 'active', 'enabled' => 'active',
'0', 'inactive', 'disabled' => 'inactive',
default => 'inactive',
};
}
// Niespodzianki: readonly class + set hook = błąd!
// Musiałem zmienić na zwykłą klasę z private(set)
// dla właściwości które mają set hook
public function __construct(
string $sku,
float $price,
private readonly string $rawStatus = '1',
public readonly string $name = '',
public readonly int $qty = 0
) {
$this->sku = $sku; // wywołuje set hook
$this->price = $price; // wywołuje set hook
}
}
$row = new ProductImportRow(' sku-001 ', 29.999, '1', 'Widget Pro', 50);
echo $row->sku; // SKU-001 (trimmed, uppercased)
echo $row->price; // 30.0 (rounded)
echo $row->status; // active (computed)
Asymmetric Visibility – najczęściej używam w ten sposób
<?php
declare(strict_types=1);
// Wzorzec który wszedł do każdej nowej klasy agregatu:
// publiczny odczyt dla wszystkich, zapis tylko wewnątrz
class ImportBatch
{
public private(set) int $processed = 0;
public private(set) int $failed = 0;
public private(set) int $skipped = 0;
public private(set) \DateTimeImmutable $startedAt;
public private(set) ?\DateTimeImmutable $finishedAt = null;
public private(set) string $status = 'pending';
public function __construct(
public readonly string $batchId,
public readonly int $totalItems
) {
$this->startedAt = new \DateTimeImmutable();
$this->status = 'running';
}
public function recordSuccess(): void
{
$this->processed++;
$this->checkCompletion();
}
public function recordFailure(string $reason): void
{
$this->failed++;
$this->checkCompletion();
}
public function recordSkip(): void
{
$this->skipped++;
$this->checkCompletion();
}
private function checkCompletion(): void
{
$done = $this->processed + $this->failed + $this->skipped;
if ($done >= $this->totalItems) {
$this->finishedAt = new \DateTimeImmutable();
$this->status = $this->failed > 0 ? 'completed_with_errors' : 'completed';
}
}
public function progressPercent(): float
{
if ($this->totalItems === 0) return 100.0;
return round(($this->processed + $this->failed + $this->skipped) / $this->totalItems * 100, 1);
}
}
// Użycie w serwisie importu
$batch = new ImportBatch('import-' . uniqid(), count($products));
foreach ($products as $product) {
try {
$this->importProduct($product);
$batch->recordSuccess();
} catch (SkipException) {
$batch->recordSkip();
} catch (\Exception $e) {
$batch->recordFailure($e->getMessage());
}
}
// Tylko odczyt z zewnątrz - settery niemożliwe
echo "{$batch->processed}/{$batch->totalItems} ({$batch->progressPercent()}%)\n";
$batch->processed = 999; // Error: Cannot modify private(set) from outside
BcMath\Number – nareszcie finansowa arytmetyka bez bólu
<?php
declare(strict_types=1);
use BcMath\Number;
// Stary kod w każdym module płatności i cen
class OldPriceCalculator
{
public function calculateTotal(array $items, float $taxRate, ?float $discount): string
{
$subtotal = '0';
foreach ($items as $item) {
$subtotal = bcadd($subtotal, bcmul((string) $item['price'], (string) $item['qty'], 4), 4);
}
if ($discount) {
$subtotal = bcsub($subtotal, (string) $discount, 4);
}
$tax = bcmul($subtotal, (string) $taxRate, 4);
$total = bcadd($subtotal, $tax, 4);
return bcround($total, 2);
}
}
// PHP 8.4 - BcMath\Number
class NewPriceCalculator
{
public function calculateTotal(array $items, Number $taxRate, ?Number $discount): Number
{
$subtotal = new Number('0');
foreach ($items as $item) {
$subtotal += new Number((string) $item['price']) * new Number((string) $item['qty']);
}
if ($discount !== null) {
$subtotal -= $discount;
}
$tax = $subtotal * $taxRate;
return ($subtotal + $tax)->round(2);
}
}
$calc = new NewPriceCalculator();
$items = [['price' => '29.99', 'qty' => 2], ['price' => '9.99', 'qty' => 1]];
$taxRate = new Number('0.23');
$discount = new Number('5.00');
$total = $calc->calculateTotal($items, $taxRate, $discount);
echo $total; // (29.99*2 + 9.99 - 5.00) * 1.23 = 85.25...
// BcMath\Number vs float - klasyczny przykład
$f1 = 0.1 + 0.2;
echo $f1; // 0.30000000000000004 - błąd float
$n1 = new Number('0.1') + new Number('0.2');
echo $n1; // 0.3 - dokładne
// Porównania
var_dump(new Number('29.99') > new Number('29.98')); // true
var_dump(new Number('10.00') === new Number('10')); // false (różne typy)
var_dump(new Number('10.00') == new Number('10')); // true (wartość)
Nowe array_find/all/any – w końcu dostępne
<?php
declare(strict_types=1);
// Te funkcje były dyskutowane od PHP 8.3 - trafiły do 8.4
// Dzisiaj zastąpiłem je w 3 miejscach i kod stał się czytelniejszy
$orders = [
['id' => 1, 'status' => 'pending', 'total' => 150.0],
['id' => 2, 'status' => 'processing', 'total' => 320.0],
['id' => 3, 'status' => 'shipped', 'total' => 89.50],
['id' => 4, 'status' => 'cancelled', 'total' => 45.0],
];
// Przed
$firstHighValue = null;
foreach ($orders as $order) {
if ($order['total'] > 200) {
$firstHighValue = $order;
break;
}
}
// PHP 8.4
$firstHighValue = array_find($orders, fn($o) => $o['total'] > 200);
// ['id' => 2, 'status' => 'processing', 'total' => 320.0]
// Walidacja czy wszystkie w dobrym stanie
$allActive = array_all(
$orders,
fn($o) => !in_array($o['status'], ['cancelled', 'refunded'], true)
);
var_dump($allActive); // false - jest cancelled
// Sprawdź czy jest jakiekolwiek duże zamówienie
$hasHighValue = array_any($orders, fn($o) => $o['total'] > 300);
var_dump($hasHighValue); // true
// Znajdź klucz zamiast wartości
$cancelledKey = array_find_key($orders, fn($o) => $o['status'] === 'cancelled');
echo $cancelledKey; // 3
Checklist migracji na PHP 8.4
# 1. Sprawdź deprecacje które stają się błędami w 8.4
# Najważniejsze: implicit nullable parameters
# function foo(string $x = null) {} // ERROR w 8.4 - zmień na ?string
# 2. PHPStan z 8.4
vendor/bin/phpstan analyse --level=8 --php-version=8.4 src/
# 3. Rector - automatyczna naprawa
vendor/bin/rector process src --php-version=8.4
# 4. Sprawdź zewnętrzne zależności
composer outdated --direct
# 5. Uruchom testy na PHP 8.4 RC zanim upgrade produkcji
docker run --rm -v $(pwd):/app php:8.4-cli \
php /app/vendor/bin/phpunit
# 6. Implicit nullable - najczęstszy problem
# Stary: function getData(string $filter = null): array
# Nowy: function getData(?string $filter = null): array
grep -r "function.*= null" src/ | grep -v "?.*=" # znajdź problematyczne przypadki
Magento 2 i PHP 8.4 – czy już?
Magento 2.4.7 oficjalnie wspiera PHP 8.3. PHP 8.4 dla Magento będzie prawdopodobnie w 2.4.8 (2025). Oznacza to:
- Własny kod modułów można pisać pod PHP 8.4 już teraz (property hooks w custom klasy domenowe)
- Platforma (rdzeń Magento + zewnętrzne moduły) wymaga 8.3 do oficjalnego wsparcia
- Możesz uruchomić Magento 2.4.7 na PHP 8.4 – najczęściej działa, ale bez gwarancji
- Dla bezpieczeństwa na produkcji: zostań na 8.3 z Magento do czasu oficjalnego wsparcia 8.4
Podsumowanie
PHP 8.4 to najlepsze wydanie PHP w historii jeśli patrzeć na gęstość wartościowych zmian. Property hooks i asymmetric visibility wchodzą do nowego kodu natychmiast. BcMath\Number to koniec ery bcadd/bcmul chainingu przy obliczeniach finansowych. Lazy Objects uzupełniają DI toolkit bez potrzeby generowania klas Proxy. Migracja z 8.3 jest gładka – główna pułapka to implicit nullable parameters które teraz są twardym błędem.
