PHP 8.0 est sorti le 26 novembre. Je le fais tourner depuis six semaines sur un projet perso et un nouveau service au boulot. C’est la version PHP la plus significative depuis 7.0, et à certains égards plus impactante, parce que les changements se renforcent mutuellement de façon utile.
JIT
Le compilateur Just-In-Time était l’annonce principale. La réalité en production est plus nuancée : pour les applications web typiques (requêtes en base, appels HTTP, rendu de templates) les gains sont modestes, parce que ces workloads sont limités par les I/O, pas par le calcul. Là où le JIT brille vraiment, c’est le code intensif en CPU : manipulation d’images, transformation de données, calcul mathématique.
Pour la plupart des applications web, l’amélioration de performance vient du travail sur le moteur en général dans 8.0, pas du JIT spécifiquement. Vaut quand même la peine de l’activer : ça ne coûte rien sur les workloads I/O-bound.
Les expressions match
switch a trois problèmes : il utilise la comparaison souple, il tombe en cascade par défaut, et il ne peut pas être utilisé comme expression. match règle les trois :
$result = match($status) {
'active', 'pending' => 'processing',
'done' => 'finished',
default => throw new \UnexpectedValueException($status),
};
Comparaison stricte. Pas de cascade. Expression qui retourne une valeur. Un match non exhaustif lève une exception. Après une semaine avec match, j’ai arrêté d’écrire des switch.
Les arguments nommés
array_slice(array: $users, offset: 0, length: 10, preserve_keys: true);
Les arguments nommés permettent de passer les arguments dans n’importe quel ordre et d’en sauter des optionnels. Le gain évident : la lisibilité sur les fonctions avec plusieurs flags booléens. Le gain moins évident : les arguments nommés survivent aux mises à jour de PHP même quand l’ordre des paramètres change, parce qu’on nomme ce qu’on veut dire.
Les attributs
Place aux docblock annotations (le style @Route, @ORM\Column sur lequel les frameworks se sont appuyés pendant des années), bienvenue à la syntaxe PHP de première classe :
#[Route('/users', methods: ['GET'])]
#[IsGranted('ROLE_ADMIN')]
public function list(): Response { ... }
Les attributs sont validés par le moteur, pas parsés depuis des strings. Le support IDE fonctionne directement, sans magie de plugin. Pour les utilisateurs de Symfony et Doctrine, c’est le vrai gain quotidien de PHP 8.0.
La promotion de constructeur
class User {
public function __construct(
public readonly int $id,
public string $name,
private ?string $email = null,
) {}
}
Propriétés déclarées et assignées en une ligne dans la signature du constructeur. Le gain de refactoring le plus immédiat dans 8.0 : chaque classe de données que j’ai touchée depuis la mise à jour fait moitié moins de lignes qu’avant.
L’opérateur nullsafe
$city = $user?->getAddress()?->getCity()?->getName();
null à n’importe quel point de la chaîne court-circuite le reste et retourne null. L’alternative était des null checks imbriqués ou une chaîne de retours anticipés. Ça se compose naturellement.
Les types union
Les arguments nommés rendent les signatures de fonctions plus explicites au site d’appel. Les types union les rendent plus honnêtes au site de déclaration :
function processInput(int|float|string $value): string|int
{
if (is_string($value)) {
return strlen($value);
}
return (int) round($value);
}
L’union int|float|string est un OU littéral. Le moteur l’impose à l’entrée et à la sortie. Avant 8.0, “ce paramètre accepte int ou float” vivait dans un docblock que rien n’imposait. Il y a aussi null comme composant de type : ?string est juste du sucre syntaxique pour string|null, les deux sont valides.
Un cas spécial : false. PHP a un tas de fonctions natives qui retournent une valeur typée en cas de succès et false en cas d’échec. Le système de types de 8.0 accommode ça : array|false, string|false. C’est une reconnaissance honnête que la codebase ne peut pas être réécrite du jour au lendemain.
Le type de retour static
static comme type de retour était possible de manière informelle via les docblocks, mais 8.0 le rend officiel. La distinction entre self et static compte dans l’héritage :
class Builder {
protected array $config = [];
public function set(string $key, mixed $value): static {
$this->config[$key] = $value;
return $this;
}
}
class SpecialBuilder extends Builder {}
$result = (new SpecialBuilder())->set('foo', 'bar');
// $result est SpecialBuilder, pas Builder
Avec self comme type de retour, cette chaîne retournerait Builder, cassant les interfaces fluides dans les sous-classes. static fait fonctionner correctement les APIs fluides à travers les hiérarchies d’héritage sans surcharges manuelles.
Le type mixed
mixed était une convention de docblock pendant des années. 8.0 en fait un vrai type qui apparaît dans les signatures :
function debug(mixed $value): void {
var_dump($value);
}
Il accepte tout : null, objets, ressources, scalaires, tableaux. Sémantiquement c’est la même chose que n’avoir aucune déclaration de type, mais c’est explicite plutôt qu’absent. La différence entre “ce paramètre est non typé” et “ce paramètre accepte intentionnellement n’importe quoi.” Vaut la peine de l’utiliser quand on écrit un utilitaire généraliste qui serait malhonnête avec un type plus étroit.
throw comme expression
Avant 8.0, throw était une instruction. Ça semble une distinction pédante jusqu’à ce qu’on tombe sur les endroits où on veut vraiment une expression :
// Dans un ternaire :
$value = $input ?? throw new \InvalidArgumentException('input required');
// Dans une arrow function :
$getId = fn(User $u) => $u->id ?? throw new \RuntimeException('no id');
// Dans un bras match (qui est déjà une expression) :
$status = match($code) {
200 => 'ok',
404 => 'not found',
default => throw new \UnexpectedValueException("unknown code: $code"),
};
Le dernier est particulièrement utile : un match sans default lancera UnhandledMatchError automatiquement, mais parfois on veut contrôler le type d’exception et le message.
catch sans variable
Petite amélioration de qualité de vie. Quand on attrape une exception mais qu’on n’utilise pas réellement l’objet, 8.0 permet d’omettre la variable :
try {
$result = $cache->get($key);
} catch (CacheMissException) {
$result = $this->compute($key);
}
Avant 8.0, il fallait écrire catch (CacheMissException $e) et ensuite soit utiliser $e soit vivre avec l’avertissement IDE sur la variable inutilisée. Aucune des deux options n’était satisfaisante.
Les fonctions string qui auraient dû exister depuis des années
Trois fonctions que chaque développeur PHP a écrites manuellement au moins une fois :
str_contains('hello world', 'world'); // true
str_starts_with('hello world', 'hell'); // true
str_ends_with('hello world', 'world'); // true
Avant 8.0, les approches habituelles étaient strpos() !== false, strncmp(), ou substr() ===, qui nécessitent toutes de s’arrêter pour se souvenir de la sémantique. Ces nouvelles fonctions sont juste directes et lisibles. Pas de regex, pas d’arithmétique d’offset.
Un tri stable
Les fonctions de tri de PHP n’étaient pas stables avant 8.0. “Pas stable” signifie que les éléments qui se comparent comme égaux pouvaient se retrouver dans n’importe quel ordre les uns par rapport aux autres. En pratique, ça causait des bugs subtils dans le code UI qui avait besoin d’un ordre cohérent, une pagination qui changeait entre les chargements, et des tests qui ne passaient que par chance.
8.0 garantit la stabilité à travers toutes les fonctions de tri : sort(), usort(), array_multisort(), et le reste. Les éléments égaux conservent leur position relative originale. C’est le comportement que la plupart des gens supposaient déjà être là.
WeakMap
7.4 apportait WeakReference pour les objets simples. 8.0 apporte WeakMap : une map où les clés (des objets) et leurs données associées peuvent être ramassées par le GC quand aucune autre référence à l’objet-clé n’existe :
class RequestCache {
private WeakMap $cache;
public function __construct() {
$this->cache = new WeakMap();
}
public function get(Request $request): Response {
return $this->cache[$request] ??= $this->compute($request);
}
}
Dès que $request n’est plus référencé ailleurs, l’entrée disparaît de la map. Pas de nettoyage manuel nécessaire. C’est le bon pattern pour la mémoïsation et les caches de propriétés calculées où on ne veut pas être la seule raison qu’un objet reste vivant.
Les nouveaux types d’exception
ValueError est levée quand une fonction reçoit le bon type mais une valeur invalide, par opposition à TypeError qui se déclenche sur les mauvais types :
array_chunk([], -5); // ValueError: array_chunk(): Argument #2 ($length) must be greater than 0
Avant 8.0, beaucoup de ces cas étaient des warnings qui retournaient false ou null. Maintenant ils lèvent des exceptions. Le moteur est plus strict, ce qui signifie qu’on attrape les problèmes plus tôt plutôt que d’obtenir des résultats bizarres quelque part en aval.
get_debug_type() et fdiv()
Deux fonctions utilitaires à connaître.
get_debug_type() retourne une représentation string normalisée de n’importe quelle valeur, pratique pour les messages d’erreur :
get_debug_type(1); // "int"
get_debug_type(1.0); // "float"
get_debug_type(null); // "null"
get_debug_type(new Foo()); // "Foo" (pas "object")
get_debug_type([]); // "array"
La différence avec gettype() : elle retourne les noms de classes pour les objets et utilise des noms normalisés ("int" pas "integer"). Exactement ce qu’on veut pour construire un message d’exception qui dit ce qu’on a reçu versus ce qu’on attendait.
fdiv() effectue une division en virgule flottante suivant IEEE 754, ce qui signifie que la division par zéro retourne INF, -INF, ou NAN au lieu d’un warning :
fdiv(10, 0); // INF
fdiv(-10, 0); // -INF
fdiv(0, 0); // NAN
Les changements qui cassent des choses
8.0 inclut aussi quelques changements qui ne sont pas des fonctionnalités, ce sont des corrections.
Le plus important : 0 == "foo" est maintenant false. En PHP 7, comparer un entier à une string non numérique castait la string en 0, donc 0 == "n'importe-quoi-non-numérique" s’évaluait à true. C’était une source persistante de bugs et de maux de tête de sécurité. PHP 8 l’inverse : l’entier est casté en string à la place :
var_dump(0 == "foo"); // bool(false) en 8.0, bool(true) en 7.x
var_dump(0 == ""); // bool(false) en 8.0, bool(true) en 7.x
var_dump(0 == "0"); // bool(true) dans les deux ("0" est numérique)
Si on s’appuyait sur ça intentionnellement, on savait déjà que c’était douteux. Si on ne savait pas qu’on s’en appuyait, 8.0 va trouver ces chemins de code.
Plusieurs fonctions qui retournaient des ressources retournent maintenant des objets propres : curl_init() retourne un CurlHandle, imagecreate() retourne un GdImage, xml_parser_create() retourne un XMLParser. Le code qui vérifie is_resource($curl) va casser, parce que is_resource() retourne false pour ces objets. La correction consiste à vérifier contre false (la valeur de retour en cas d’échec) plutôt que de vérifier le type du cas de succès.
PHP 8.0 est le genre de version où les fonctionnalités se renforcent mutuellement. Les attributs se marient bien avec la promotion de constructeur. Match s’associe naturellement avec les types union. Les fonctions string réduisent le bruit qui cachait l’intention. Les corrections sont parfois cassantes, mais elles poussent le langage vers une cohérence qu’il aurait dû avoir depuis des années.