Ein Kollege hat mir letzte Woche einen Diff geschickt, dazu genau eine Zeile Kommentar: „Warum gibt es davon jetzt ZWEI?“ Im Diff wurde in unserem Webhook-Validator ein parse_url()-Aufruf durch Uri\WhatWg\Url ersetzt, und im selben PR nutzte eine andere Datei Uri\Rfc3986\Uri für unsere internen Service-Identifier. Er hat das so gelesen, als könne sich PHP 8.5 nicht entscheiden. Ich lese es als das Ehrlichste, was die URL-Geschichte in PHP erlebt hat, seit parse_url() damals mit PHP 4 kam. Dass es zwei Klassen gibt, ist die Lösung. Ein Überbleibsel ist es nicht.

Kurz zur Auffrischung, falls du 8.5 noch nicht angefasst hast. Die neue URI-Extension ist fest mit dabei, du musst also nichts über Composer nachziehen. Uri\Rfc3986\Uri basiert auf der Bibliothek uriparser und folgt RFC 3986, der allgemeinen Grammatik für Identifier: Die deckt URNs wie urn:uuid:... ab, eigene Schemes und relative Referenzen wie /path/foo. Uri\WhatWg\Url läuft auf Lexbor, der Engine, die PHP 8.4 schon für die DOM-API reingeholt hat, und liest URLs genau so, wie ein Browser es tut. Eine relative Referenz nimmt sie nur an, wenn du eine Basis mitgibst, und sie weiß, dass münchen.example und seine Punycode-Form derselbe Host sind. Deshalb bekommst du getUnicodeHost(), getAsciiHost() und toAsciiString(). Beide Klassen sind immutable, beide bieten with*()-Methoden im PSR-7-Stil, und beide haben ein equals(), dem du UriComparisonMode::IncludeFragment übergeben kannst, wenn das Fragment mitzählen soll.

Dass mir die Aufteilung so wichtig ist, liegt an einer bestimmten Sorte Bug, die mich schon ein paar Nächte gekostet hat. Dein Validator liest eine URL und entscheidet, dass der Host erlaubt ist. Dann liest dein HTTP-Client, oder curl darunter, oder ein Proxy weiter hinten, dieselben Bytes und verbindet sich ganz woanders hin. Keine einzelne Komponente macht etwas falsch. Sie sind sich nur uneinig über den String, und genau in dieser Uneinigkeit wohnt ein SSRF. Die PHP-Doku sagt das auch ziemlich direkt: Wenn Validierung und Abruf der Ressource über verschiedene Parser laufen, können Sicherheitslücken entstehen. parse_url() hat nie versucht, irgendeinem Standard zu folgen, konnte also mit so ziemlich allem uneins sein, und jahrelang haben wir so getan, als wäre dieses Array mit Keys, die mal da sind und mal nicht, ein echtes Parsing.

Mit einer einzigen „Uri“-Klasse hätte das Internals-Team einen Gewinner küren müssen, und der unterlegene Standard wäre in die Form des Gewinners gebogen worden. Kennst du noch den alten Witz, dass PHP für alles drei Wege hat? Das hier ist der umgekehrte Fall: zwei Wege, weil es in der Welt tatsächlich zwei Definitionen gibt, und die Sprache hat beschlossen, das nicht länger zu überdecken. Wenn der String an einen HTTP-Client geht, aus einem Browser kommt oder als Redirect zurückgeschickt wird, nimm WHATWG, denn der Rest der Kette liest ihn ohnehin so. Wenn es ein Identifier ist, den deine eigenen Systeme erfunden haben, etwa eine URN, eine Queue-Adresse oder ein my-app://-Deep-Link, nimm RFC 3986, denn ein Browsermodell hat da nichts zu melden.

Das beste Gegenargument verdient ein faires Gehör. Die meisten von uns wollten nie Standard-Juristen werden. Ein Junior, der nur den Host aus einem Link braucht, muss jetzt erst verstehen, warum es zwei Specs gibt, bevor er eine einzige Zeile schreibt, und die Seite, die das erklärt, ist länger, als die alte parse_url()-Doku je war. PSR-7-Implementierungen kommen seit Jahren mit immutablen URIs klar, und Bibliotheken wie league/uri decken die Randfälle seit einem Jahrzehnt ab. Man kann also berechtigt fragen, was der Core außer einem zweiten Vokabular beiträgt. Obendrein gibt es parse_url() immer noch, es ist immer noch schnell und immer noch völlig okay, um den Pfad aus einer URL zu ziehen, die du dreißig Zeilen weiter oben selbst gebaut hast.

Fast alles davon akzeptiere ich und lande trotzdem auf derselben Seite, aus zwei Gründen. Erstens kann eine Userland-Bibliothek nicht deinen ganzen Stack auf eine Linie bringen. Eine Core-Klasse, auf der die HTTP-Clients der Frameworks, Validatoren und PSR-7-Adapter gleichermaßen aufbauen können, schafft das mit der Zeit schon, und ein gemeinsamer Parser ist genau das, was die Lücke zwischen den Lesarten schließt. Zweitens die Lernkurve. Herauszufinden, ob dein String ein Weblink oder ein Identifier ist, ist die eigentliche Frage, und parse_url() hat dich ihr ausweichen lassen, bis ein Bugreport dich zur Antwort gezwungen hat. Dass Url::parse() null zurückgibt und ein $errors-Array mit UrlValidationError-Objekten füllt, ist für Formulareingaben außerdem schlicht angenehmer als ein try/catch um jedes Feld.

Deshalb gilt in unserer Codebase folgende Regel, und du darfst sie gern klauen. Überall, wo eine URL eine Vertrauensgrenze überschreitet (Nutzereingaben, Webhooks, Redirects, alles, was in einem ausgehenden Request landet), ist parse_url() verboten, und der Parser, der validiert, muss dieselbe Klasse sein, die der abrufende Code verwendet. Überall sonst stellst du um, wenn du ohnehin gerade in der Datei bist. Dreihundert harmlose Aufrufstellen in einem Sprint umzuschreiben, macht aus einer vernünftigen Migration nur einen Fall für git blame.

Was ich noch nicht raushabe, ist die Naht zwischen den beiden. Dein PSR-7-Request-Objekt hält eine URI, dein ausgehender Client will vermutlich WHATWG-Semantik, und dein Router denkt wahrscheinlich in RFC-3986-Begriffen. An welcher Stelle deiner Anwendung konvertierst du, und welche Klasse landet in deinen Domain-Objekten: überall ein Typ oder beide, mit einer explizit gezogenen Grenze? Ich würde wirklich gern sehen, wie eure Teams diese Linie ziehen.