PHP 7 vs PHP 8 — główne różnice, które zmieniają sposób pisania kodu
Co realnie zmienia PHP 8 względem 7: typy, składnia, JIT, ostrzejsze błędy — i na co uważać przy migracji.
Problem
PHP 7.4 nie jest wspierane od listopada 2022, a mimo to sporo projektów (PrestaShop 1.7, stare Laravele, własne CMS-y) nadal na nim siedzi. Przejście na PHP 8 to nie tylko „szybciej" — to zmiana semantyki w kilku miejscach, które w 7 przechodziły bez ostrzeżenia, a w 8 rzucają TypeError albo zwracają inny wynik. Poniżej to, co faktycznie ma znaczenie w codziennym kodzie.
Sprawdzenie wersji na serwerze:
$php -vPHP 8.3.12 (cli) (built: Sep 26 2024 12:00:00) (NTS)
Nowa składnia, która zostaje na stałe
Union types
W 7 typ „string albo int" opisywałeś w docblocku. W 8 jest w sygnaturze:
// PHP 7
/** @param int|string $id */
function find($id) {}
// PHP 8
function find(int|string $id): ?User {}
mixed też jest oficjalnym typem — kończy z „brak typu = mixed".
Named arguments
Argumenty po nazwie, w dowolnej kolejności, z pominięciem opcjonalnych:
// PHP 7 — trzeba podać wszystko po drodze
htmlspecialchars($s, ENT_QUOTES | ENT_HTML5, 'UTF-8', false);
// PHP 8
htmlspecialchars($s, double_encode: false);
Constructor property promotion
// PHP 7
class Money {
private int $amount;
private string $currency;
public function __construct(int $amount, string $currency) {
$this->amount = $amount;
$this->currency = $currency;
}
}
// PHP 8
class Money {
public function __construct(
private int $amount,
private string $currency,
) {}
}
Trailing comma w liście parametrów też jest legalna dopiero od 8.0.
match
Zamiennik switch z porównaniem ścisłym (===), bez break i z wartością zwracaną:
$label = match ($status) {
200, 201 => 'ok',
404 => 'not found',
default => 'error',
};
Brak pasującej gałęzi i brak default → UnhandledMatchError. W switch po prostu nic się nie działo.
Nullsafe operator
// PHP 7
$city = null;
if ($user !== null && $user->getAddress() !== null) {
$city = $user->getAddress()->getCity();
}
// PHP 8
$city = $user?->getAddress()?->getCity();
Atrybuty zamiast docblocków
Metadane jako składnia języka, nie komentarze parsowane regexem:
#[Route('/users/{id}', methods: ['GET'])]
public function show(int $id) {}
Symfony i Doctrine przeszły z annotacji w docblockach na atrybuty, a Laravel (który annotacji nigdy nie używał) od wersji 11 też je dodaje, np. #[ObservedBy] i #[ScopedBy]. To jeden z powodów, dla których nowe wersje frameworków nie działają na 7.
throw jako wyrażenie
$user = $repo->find($id) ?? throw new NotFoundException();
Jak to gra z finally i return w bloku try, opisałem w osobnym wpisie o try/finally w PHP — semantyka jest ta sama w 7 i 8, ale throw w wyrażeniu otwiera nowe kombinacje.
Nowe funkcje w stdlib
Koniec z strpos($h, $n) !== false:
str_contains('haystack', 'st'); // true
str_starts_with('haystack', 'hay');
str_ends_with('haystack', 'ack');
get_debug_type(1.5); // "float", nie "double" jak gettype()
WeakMap, Stringable (automatycznie dla klas z __toString()), static jako typ zwracany.
Zmiany semantyki — tu boli przy migracji
Porównanie string vs liczba
Najbardziej podstępna zmiana. W 7 0 == "foo" było true, bo string konwertował się do 0. W 8 to liczba konwertuje się do stringa, jeśli string nie jest numeryczny:
var_dump(0 == "foo"); // PHP 7: true PHP 8: false
var_dump(0 == ""); // PHP 7: true PHP 8: false
var_dump("1" == "01"); // oba: true (numeryczne stringi nadal porównywane liczbowo)
Warningi stają się wyjątkami
Rzeczy, które w 7 logowały Warning i szły dalej, w 8 rzucają TypeError / ValueError:
strlen([]); // PHP 7: Warning, zwraca null PHP 8: TypeError
array_chunk([], 0); // PHP 7: Warning PHP 8: ValueError
Funkcje wbudowane wymagają typów zgodnych z sygnaturą — null przekazany do nienullowalnego parametru funkcji wbudowanej (np. strlen(null)) daje od 8.1 deprecation, a int do array — TypeError już od 8.0. Dla funkcji użytkownika nic się nie zmieniło: tam TypeError był od 7.0.
Operatory arytmetyczne na tablicach i obiektach
new stdClass + 1; // PHP 7: Notice "could not be converted to number", wynik 2 PHP 8: TypeError
[] % 5; // PHP 7: cicha konwersja tablicy na 0, wynik 0 PHP 8: TypeError
$fp % 5; // PHP 7: cicha konwersja zasobu na jego ID PHP 8: TypeError
W 7 było niespójnie: [] + 1 czy [] - 1 rzucały fatal error „Unsupported operand types" od zawsze, ale %, operatory bitowe, obiekty i zasoby przechodziły z notice albo zupełnie po cichu. W 8 każdy operator arytmetyczny i bitowy na tablicy, obiekcie bez przeciążenia lub zasobie rzuca TypeError. Jedyny wyjątek: array + array nadal działa jako merge.
Kolejność konkatenacji
$a = 1; $b = 2;
echo "sum: " . $a + $b;
// PHP 7: ("sum: " . $a) + $b → "sum: 1" rzutowane na 0, wynik 2 + Warning "non-numeric value"
// PHP 8: "sum: " . ($a + $b) → "sum: 3", czyli to, co chciałeś
W 8 +/- mają wyższy priorytet niż .. PHP 7.4 ostrzegało o tym deprecation notice, ale kod, który polegał na starym zachowaniu, w 8 zmienia wynik po cichu.
Zasoby jako obiekty
curl_init(), gd, openssl zwracają obiekty (CurlHandle, GdImage), nie resource. is_resource($ch) zwraca false:
// PHP 7
if (is_resource($ch)) { curl_close($ch); }
// PHP 8
if ($ch instanceof CurlHandle) { /* curl_close jest no-op, GC zamyka sam */ }
Usunięte i zdeprecjonowane
create_function(),each(),money_format()— usunięte.${var}w stringach — deprecated w 8.2.- Dynamiczne właściwości (
$obj->foo = 1bez deklaracji) — deprecated w 8.2, chyba że#[AllowDynamicProperties]. utf8_encode()/utf8_decode()— deprecated w 8.2, do wycięcia w 9. Jeśli normalizujesz Unicode, użyjintl— przykłady w wpisie o normalizacji NFD.
JIT
PHP 8 dodaje kompilator JIT w OPcache. Konfiguracja:
opcache.enable=1
opcache.jit_buffer_size=128M
opcache.jit=tracing
Weryfikacja — w CLI OPcache jest domyślnie wyłączony, więc bez opcache.enable_cli=1 opcache_get_status() zwróci false:
$php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=128M \-r 'var_dump(opcache_get_status()["jit"]["enabled"]);'bool(true)
Pod www (FPM/Apache) wystarczy phpinfo() albo opcache_get_status() w dowolnym skrypcie — tam ustawienia z php.ini już działają.
Co doszło w 8.1–8.4
Skoro migrujesz, nie zatrzymuj się na 8.0 — 8.0 i 8.1 są już bez wsparcia:
- 8.1:
enum,readonlyproperty,neww domyślnych wartościach parametrów, Fibers,never,array_is_list(). - 8.2:
readonly class, DNF types ((A&B)|null),true/false/nulljako samodzielne typy. - 8.3: typowane stałe klas,
#[Override],json_validate(). - 8.4: property hooks (gettery/settery bez metod), asymmetric visibility (
public private(set)),newbez nawiasów w łańcuchu.
enum Status: string {
case Active = 'active';
case Banned = 'banned';
public function label(): string {
return match ($this) {
self::Active => 'Aktywny',
self::Banned => 'Zbanowany',
};
}
}
Status::from('active')->label(); // "Aktywny"
Nowoczesne stacki — np. Filament 5 na Laravel 12 — zakładają minimum 8.2 i opierają się na enumach, atrybutach i readonly w każdym module.
Migracja w praktyce
Statyczna analiza zgodności, zanim dotkniesz serwera:
$composer require --dev phpcompatibility/php-compatibility:"^10@dev" dealerdirect/phpcodesniffer-composer-installer$vendor/bin/phpcs -p src/ --standard=PHPCompatibility --runtime-set testVersion 8.3
Plugin phpcodesniffer-composer-installer rejestruje standard w PHPCS — bez niego --standard=PHPCompatibility kończy się błędem „standard not installed". Wersja 10 jest potrzebna, bo stabilna 9.x nie zna reguł dla PHP 8.
Automatyczne przepisanie składni:
$composer require --dev rector/rector$vendor/bin/rector process src/ --dry-run
Minimalny rector.php:
<?php
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src'])
->withPhpSets(php83: true);
withPhpSets() włącza wszystkie zestawy do wskazanej wersji. Starsze poradniki pokazują LevelSetList::UP_TO_PHP_83 — to API z Rectora 1.x, w 2.x już go nie ma.
Co się dzieje pod spodem
Większość „ostrzejszych" zachowań w 8 to sprzątanie po dwóch dekadach niejawnych konwersji. RFC Saner string to number comparisons i Consistent type errors for internal functions zunifikowały to, jak silnik traktuje funkcje wbudowane i użytkownika — w 7 funkcja z strict_types=0 napisana przez Ciebie rzucała TypeError na zły typ, a wbudowana tylko logowała warning i zwracała null. W 8 obie zachowują się tak samo.
JIT działa na poziomie opcode'ów OPcache: śledzi „gorące" ścieżki (tryb tracing) i kompiluje je do kodu maszynowego — do 8.3 przez DynASM, od 8.4 przez nowy framework IR, na którym JIT został przepisany. Dlatego bez włączonego OPcache JIT nie robi nic — i dlatego w CLI (php script.php) domyślnie go nie zobaczysz, chyba że ustawisz opcache.enable_cli=1.
Zobacz też
- PHP try/finally — kiedy finally naprawdę się wykonuje — semantyka wyjątków, która w 8 zyskała
throwjako wyrażenie. - Normalizacja NFD w PHP i JS — zamiennik dla wycinanego
utf8_encode(). - Wszystkie wpisy z tagiem #php.