planeta jupiter planeta tierra

Los operadores PHP que casi nadie usa (y deberían)

· 7 min de lectura · PHP

El logotipo de PHP
nave extraterrestre
El logotipo de PHP

PHP lleva años añadiendo operadores que hacen en un símbolo lo que antes eran cinco líneas de if. Y sin embargo sigues viendo isset() anidados y usort con tres if dentro. Estos cuatro —??, ?->, ... y <=>— están en el lenguaje desde hace tiempo, no hace falta instalar nada, y cada uno borra un tipo de ruido distinto.

?? — el que asume que puede no haber nada

El null coalescing (PHP 7.0) devuelve lo de la izquierda si existe y no es null; si no, lo de la derecha. La gracia está en que no avisa cuando la clave o la variable no existe: lleva un isset() dentro.

// antes
$page = isset($_GET['page']) ? (int) $_GET['page'] : 1;

// ahora
$page = (int) ($_GET['page'] ?? 1);

Se encadena, y evalúa de izquierda a derecha hasta encontrar el primero que no sea nulo:

$locale = $request->query->get('lang')
    ?? $user?->getLocale()
    ?? $_ENV['DEFAULT_LOCALE']
    ?? 'es';

Y tiene una versión de asignación, ??= (PHP 7.4), que solo asigna si el destino está vacío. Es la forma corta del caché perezoso de toda la vida:

// antes
if (!isset($this->config['timeout'])) {
    $this->config['timeout'] = 30;
}

// ahora
$this->config['timeout'] ??= 30;

Lo que confunde a todo el mundo es la diferencia con ?: (el ternario corto). No son intercambiables:

$a = ['nombre' => '0'];

$a['nombre'] ?: 'anónimo';   // 'anónimo'  ← '0' es falsy
$a['nombre'] ?? 'anónimo';   // '0'        ← existe y no es null

$a['apodo'] ?: 'anónimo';    // 'anónimo' + Warning: undefined array key
$a['apodo'] ?? 'anónimo';    // 'anónimo' en silencio

Regla práctica: ?? pregunta «¿existe?», ?: pregunta «¿tiene valor?». Para un precio, un contador o un '0' que viene de un formulario, esa diferencia es un bug esperando a pasar.

?-> — el que no revienta si es null

El nullsafe (PHP 8.0) llama al método o lee la propiedad solo si el objeto existe. Si es null, devuelve null y sigue adelante en vez de lanzar el clásico Call to a member function on null.

// antes
$ciudad = null;

if (null !== $pedido) {
    $cliente = $pedido->getCliente();

    if (null !== $cliente) {
        $direccion = $cliente->getDireccion();

        if (null !== $direccion) {
            $ciudad = $direccion->getCiudad();
        }
    }
}

// ahora
$ciudad = $pedido?->getCliente()?->getDireccion()?->getCiudad();

Y el detalle que mucha gente no sabe: cortocircuita la cadena entera. En cuanto uno da null, lo que queda a la derecha ni se ejecuta. No hace falta poner ?-> en todos los eslabones para estar a salvo, aunque ponerlo solo donde de verdad puede haber null documenta mejor el código.

Ojo con tres cosas:

  • No funciona para escribir. $a?->b = 1; es error de sintaxis. Solo lee.
  • No existe para arrays. Para $datos['user']['name'] lo que tienes es ??, no ?->.
  • Puede tapar un problema de verdad. Si ese objeto nunca debería ser null, el ?-> convierte una excepción clara en un dato vacío que aparecerá tres pantallas más allá. Úsalo cuando el null sea un caso legítimo, no para silenciar.

Y un aviso si trabajas con Doctrine: encadenar ?-> sobre relaciones es muy cómodo, pero cada eslabón puede disparar su propia consulta. Es una de las formas de acabar con un N+1 sin haber escrito un solo bucle.

... — el que reparte y el que recoge

El spread hace dos cosas según dónde lo pongas. En la firma de una función recoge argumentos sueltos en un array:

function sumar(int ...$numeros): int
{
    return array_sum($numeros);
}

sumar(1, 2, 3);        // 6
sumar(...[1, 2, 3]);   // 6

En una llamada hace lo contrario: desmonta el array en argumentos. Eso mata al call_user_func_array:

// antes
$respuesta = call_user_func_array([$servicio, 'crear'], $parametros);

// ahora
$respuesta = $servicio->crear(...$parametros);

También fusiona arrays (PHP 7.4), y para listas es bastante más legible que array_merge:

$base       = ['php', 'symfony'];
$adicionales = ['doctrine', 'twig'];

$todas = [...$base, ...$adicionales, 'redis'];
// ['php', 'symfony', 'doctrine', 'twig', 'redis']

Desde PHP 8.1 admite claves de texto, y ahí gana de verdad, porque combina con la sintaxis de array y las claves repetidas ganan por la derecha:

$porDefecto = ['timeout' => 30, 'reintentos' => 3];
$delCliente = ['timeout' => 5];

$opciones = [...$porDefecto, ...$delCliente];
// ['timeout' => 5, 'reintentos' => 3]

Y desde PHP 8.1 también se puede desplegar un array asociativo como argumentos con nombre, que es una forma muy limpia de construir un objeto desde una configuración:

$datos = ['nombre' => 'Ada', 'email' => 'ada@example.com'];

$usuario = new Usuario(...$datos); // equivale a new Usuario(nombre: 'Ada', email: '…')

La trampa: con claves numéricas, [...$a, ...$b] reindexa, mientras que array_merge hace lo mismo pero + no. Si dependes de conservar los índices, ninguno de los dos es tu operador.

<=> — la nave espacial que ordena

El operador spaceship (PHP 7.0) compara dos valores y devuelve -1, 0 o 1. Justo lo que pide usort, que es donde vive:

// antes
usort($productos, function (Producto $a, Producto $b) {
    if ($a->getPrecio() === $b->getPrecio()) {
        return 0;
    }

    return $a->getPrecio() < $b->getPrecio() ? -1 : 1;
});

// ahora
usort($productos, fn (Producto $a, Producto $b) => $a->getPrecio() <=> $b->getPrecio());

Para invertir el orden, das la vuelta a los operandos: $b <=> $a. Y para ordenar por varios criterios, el truco bueno: compara arrays. PHP los recorre elemento a elemento y se queda en el primero que difiere.

// por prioridad (desc), y a igualdad, por fecha (asc) y nombre
usort($tareas, fn (Tarea $a, Tarea $b) =>
    [$b->getPrioridad(), $a->getFecha(), $a->getNombre()]
    <=>
    [$a->getPrioridad(), $b->getFecha(), $b->getNombre()]
);

Eso son tres criterios en una expresión, sin un solo if. Dos detalles: los arrays deben tener el mismo número de elementos (si no, gana el más largo sin mirar el contenido), y los objetos DateTimeInterface se comparan bien entre ellos, así que no hace falta pasarlos a timestamp.

Una cosa más: desde PHP 8 las comparaciones entre cadena y número son sensatas (0 == 'foo' ya es false), pero <=> sigue aplicando las reglas de coerción del lenguaje. Comparando tipos distintos, los resultados pueden sorprender; compara manzanas con manzanas.

El resumen, por si vienes con prisa

Operador Desde Para qué
?? 7.0 Valor por defecto cuando la clave puede no existir.
??= 7.4 Asignar solo si aún no hay valor.
?-> 8.0 Cadenas de llamadas donde algún eslabón puede ser null.
... 5.6 / 7.4 / 8.1 Variádicas, desplegar argumentos y fusionar arrays.
<=> 7.0 Comparadores de usort, simples o por varios criterios.

Lo que se rompe si no lo sabes

  • ?? no es ?:. Con '0', 0, '' o un array vacío se comportan distinto, y la mitad de los valores por defecto mal puestos vienen de ahí.
  • ?-> no es un try/catch. Si el método existe pero lanza una excepción, la excepción sale igual. Solo cubre el null del objeto.
  • ?-> oculta la causa. Un null que viaja por media aplicación es más difícil de depurar que un error en la línea exacta.
  • El spread con claves numéricas reindexa. Si el array era un mapa de ids, después ya no lo es.
  • Desplegar con nombres falla si sobra una clave. new Usuario(...$datos) lanza Error si $datos trae una clave que el constructor no acepta. Filtra antes.
  • <=> con arrays de distinta longitud no compara: decide por el tamaño. Construye siempre listas del mismo número de criterios.

Y ya está

Ninguno de los cuatro es una técnica avanzada: son símbolos que llevan años en el lenguaje y que están ahí precisamente para quitar el andamiaje. La próxima vez que escribas un isset() ternario, tres if por un null o un comparador de seis líneas, prueba si el operador lo dice más corto. Casi siempre sí.

← Volver al blog

Mi hija de 11 años me ayudó a crear el diseño de este portfolio.