24 posts 93 tags 7 domains

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:

bash
$php -v
PHP 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
// 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
// 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
// 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ą:

php
$label = match ($status) {
    200, 201 => 'ok',
    404      => 'not found',
    default  => 'error',
};

Brak pasującej gałęzi i brak defaultUnhandledMatchError. W switch po prostu nic się nie działo.

Nullsafe operator

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

php
#[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

php
$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:

php
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:

php
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:

php
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 arrayTypeError 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

php
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

php
$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
// 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 = 1 bez 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żyj intl — przykłady w wpisie o normalizacji NFD.

JIT

PHP 8 dodaje kompilator JIT w OPcache. Konfiguracja:

ini
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:

bash
$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, readonly property, new w domyślnych wartościach parametrów, Fibers, never, array_is_list().
  • 8.2: readonly class, DNF types ((A&B)|null), true/false/null jako samodzielne typy.
  • 8.3: typowane stałe klas, #[Override], json_validate().
  • 8.4: property hooks (gettery/settery bez metod), asymmetric visibility (public private(set)), new bez nawiasów w łańcuchu.
php
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:

bash
$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:

bash
$composer require --dev rector/rector
$vendor/bin/rector process src/ --dry-run

Minimalny rector.php:

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ż