La semana pasada un compañero me mandó un diff con un solo comentario: "¿por qué ahora hay DOS de estos?". El diff cambiaba una llamada a parse_url() en nuestro validador de webhooks por Uri\WhatWg\Url, y en el mismo PR otro archivo usaba Uri\Rfc3986\Uri para los identificadores de nuestros servicios internos. Él lo interpretó como que PHP 8.5 no termina de decidirse. Yo lo veo como lo más honesto que le ha pasado a la historia de las URLs en PHP desde que parse_url() apareció allá por PHP 4. Tener dos clases es la solución, no un resto que se quedó por ahí.

Un repaso rápido por si todavía no has tocado la 8.5. La nueva extensión URI viene incluida, así que no hay que instalar nada con Composer. Uri\Rfc3986\Uri se apoya en la librería uriparser y sigue el RFC 3986, la gramática general de identificadores, que abarca URNs como urn:uuid:..., esquemas propios y referencias relativas como /path/foo. Uri\WhatWg\Url funciona sobre Lexbor, el motor que PHP 8.4 ya incorporó para la API de DOM, y lee las URLs igual que un navegador. No acepta una referencia relativa si no le pasas una base, y sabe que münchen.example y su forma en Punycode son el mismo host, por eso tienes getUnicodeHost(), getAsciiHost() y toAsciiString(). Las dos clases son inmutables, las dos te ofrecen métodos with*() al estilo de PSR-7 y las dos tienen un equals() al que puedes pasarle UriComparisonMode::IncludeFragment si quieres que el fragmento cuente.

Si me importa tanto la división es por un tipo concreto de bug que ya me ha costado unas cuantas noches. Tu validador lee una URL y decide que el host está permitido. Luego tu cliente HTTP, o curl por debajo, o un proxy más adelante, lee los mismos bytes y se conecta a otro sitio. Ningún componente se equivoca por sí solo. Simplemente no se ponen de acuerdo sobre la cadena, y en ese desacuerdo vive un SSRF. La propia documentación de PHP lo dice: validar con un parser y hacer la petición con otro puede abrir agujeros de seguridad. parse_url() nunca intentó seguir ningún estándar, así que podía discrepar de prácticamente todo, y durante años fingimos que ese array de claves que a veces están y a veces no contaba como un parseo.

Con una única clase "Uri", el equipo de internals habría tenido que elegir un ganador, y el estándar perdedor habría acabado deformado para encajar en el molde del otro. ¿Te acuerdas del chiste de que PHP tiene tres formas de hacer cualquier cosa? Este es el caso contrario: dos formas porque el mundo de verdad tiene dos definiciones, y el lenguaje ha decidido dejar de disimularlo. Si la cadena va a un cliente HTTP, viene de un navegador o la vas a devolver como redirección, usa WHATWG, porque el resto de la cadena ya la lee así. Si es un identificador que se han inventado tus propios sistemas, como un URN, la dirección de una cola o un deep link my-app://, usa RFC 3986, porque un modelo de navegador no pinta nada juzgando eso.

El mejor argumento en mi contra merece que lo escuchemos bien. La mayoría nunca quisimos convertirnos en abogados de estándares. Un junior que solo necesita sacar el host de un enlace ahora tiene que entender por qué existen dos especificaciones antes de escribir una línea, y la página que lo explica es más larga de lo que nunca fue la documentación de parse_url(). Las implementaciones de PSR-7 llevan años manejando URIs inmutables, y librerías como league/uri cubren los casos raros desde hace una década, así que es razonable preguntarse qué aporta el núcleo aparte de un segundo vocabulario. Y encima parse_url() sigue ahí, sigue siendo rápido y sigue valiendo para sacar el path de una URL que tú mismo construiste treinta líneas más arriba.

Acepto casi todo eso y aun así llego a la misma conclusión, por dos motivos. El primero es que una librería en userland no puede hacer que todo tu stack esté de acuerdo. Una clase del núcleo sobre la que puedan construir los clientes HTTP de los frameworks, los validadores y los adaptadores de PSR-7 sí puede, con el tiempo, y un parser compartido es justo lo que cierra ese hueco de discrepancias. El segundo es la curva de aprendizaje. Averiguar si tu cadena es un enlace web o un identificador es la pregunta de verdad, y parse_url() te dejaba esquivarla hasta que un bug report te obligaba a responderla. Además, que Url::parse() devuelva null y rellene un array $errors con objetos UrlValidationError es claramente más cómodo para los formularios que un try/catch alrededor de cada campo.

Así que esta es mi regla para nuestro código, y puedes copiarla sin problema. Allí donde una URL cruce una frontera de confianza (entrada del usuario, webhooks, redirecciones, cualquier cosa que acabe en una petición saliente), parse_url() queda prohibido, y el parser que valida tiene que ser la misma clase que usa el código que hace la petición. En todo lo demás, migra cuando ya estés tocando ese archivo. Reescribir trescientas llamadas inofensivas en un solo sprint solo convierte una migración sensata en un incidente que acaba en el historial de blame.

Lo que todavía no tengo resuelto es la costura entre las dos. Tu objeto de petición PSR-7 guarda una URI, tu cliente saliente seguramente quiere semántica WHATWG y tu router probablemente piensa en términos de RFC 3986. ¿En qué punto de tu aplicación haces la conversión, y qué clase metes en tus objetos de dominio: un solo tipo en todas partes, o los dos con la frontera bien explícita? Me encantaría ver cómo lo está resolviendo tu equipo.