Rarezas de PHP: las comparaciones, los arrays, el texto, los números y las decisiones de diseño que más sorprenden, comprobadas en PHP 8.5, con la explicación de cada una y qué hacer para que no te muerdan.
PHP nació en 1994 como un conjunto de herramientas para páginas personales y creció sin un diseño previo, añadiendo funciones a medida que se necesitaban. Treinta años después, PHP 8 ha corregido muchas de sus decisiones más criticadas, pero no todas se pueden cambiar sin romper millones de aplicaciones. Estas son las que siguen ahí en PHP 8.5 y conviene conocer. Las vemos en el curso de pasada; aquí, todas juntas, cada una con la salida real.
Comparaciones y tipos
1. Textos distintos que son «iguales»
<?php
var_dump('1' == '01');
var_dump('10' == '1e1');
var_dump(100 == '1e2');
var_dump('abc' == 0);
var_dump(null == false);
var_dump([] == false);salidabool(true) bool(true) bool(true) bool(false) bool(true) bool(true)
Con ==, dos textos que parecen números se comparan como números: '1e1' es diez en notación científica. Desde PHP 8, un texto no numérico ya no es igual a 0 (en PHP 7, 'abc' == 0 daba true), pero null, false y el array vacío siguen siendo iguales entre sí.
Qué hacer: === y !== siempre, salvo que quieras la conversión a propósito.
2. empty() de un texto que no está vacío
<?php
var_dump(empty('0'));
var_dump(empty('0.0'));
var_dump(empty(' '));
var_dump(empty([]));salidabool(true) bool(false) bool(false) bool(true)
empty devuelve true para todo lo que PHP considera falso, y el texto '0' lo es, aunque tenga un carácter. Un formulario con una cantidad de 0 parece «vacío».
Qué hacer: compara con lo que quieres comprobar: $s === '', $n === 0 o $a === [].
3. ?: y ?? no son lo mismo
<?php $cantidad = '0'; var_dump($cantidad ?: 'sin cantidad'); var_dump($cantidad ?? 'sin cantidad'); $vacio = null; var_dump($vacio ?: 'sin valor'); var_dump($vacio ?? 'sin valor');
salidastring(12) "sin cantidad" string(1) "0" string(9) "sin valor" string(9) "sin valor"
?: (el operador «Elvis») usa el valor por omisión cuando lo de la izquierda es falso, y '0' lo es. ?? solo cuando es null o no existe.
Qué hacer: ?? para valores que pueden faltar; ?: solo si de verdad quieres tratar el 0 y el texto vacío como ausentes.
4. isset() de una variable que existe
<?php
$config = ['tema' => null];
var_dump(isset($config['tema']));
var_dump(array_key_exists('tema', $config));
$x = null;
var_dump(isset($x));salidabool(false) bool(true) bool(false)
isset responde a «¿existe y no es null?», no a «¿existe?». Una clave guardada con valor null no está «puesta» para isset.
Qué hacer: array_key_exists cuando null sea un valor válido.
5. in_array encuentra lo que no está
<?php
var_dump(in_array('1e1', ['10']));
var_dump(in_array(null, [0]));
var_dump(in_array('1e1', ['10'], true));salidabool(true) bool(true) bool(false)
in_array compara con == salvo que le pases true como tercer argumento. Lo mismo ocurre con array_search y array_keys con búsqueda.
Qué hacer: in_array($v, $lista, true), siempre. Es especialmente grave en comprobaciones de permisos.
6. strict_types solo vale en el archivo que llama
<?php
// Este archivo NO declara strict_types. La función viene de inc/estricto.php, que sí lo declara.
require __DIR__ . '/inc/estricto.php';
var_dump(doble('5'));salidaint(10)
La función doble está en un archivo con declare(strict_types=1), y aun así acepta el texto '5': lo que cuenta es la declaración del archivo desde el que se llama, y este no la tiene. Un archivo sin la declaración en un proyecto estricto reintroduce las conversiones automáticas.
Qué hacer: declare(strict_types=1); en todos los archivos, y una regla del analizador o del estilo de código que lo exija.
Arrays
7. Claves que cambian de tipo
<?php $a = ['1' => 'a', '01' => 'b', '1.5' => 'c', true => 'd', null => 'e']; var_dump($a);
salida
Deprecated: Using null as an array offset is deprecated, use an empty string instead in /curso/claves.php on line 2
array(4) {
[1]=>
string(1) "d"
["01"]=>
string(1) "b"
["1.5"]=>
string(1) "c"
[""]=>
string(1) "e"
}Las claves de texto que son un entero en forma canónica ('1') se convierten en enteros; las demás ('01', '1.5') se quedan como texto. true se convierte en 1, y null, en el texto vacío, algo que PHP 8.5 ya marca como obsoleto, como ves en el aviso. Por eso '1' y true acabaron en el mismo elemento.
Qué hacer: no mezcles tipos en las claves, y no confíes en que una clave numérica que llega como texto siga siéndolo.
8. La referencia que sobrevive al foreach
<?php
$a = [1, 2, 3];
foreach ($a as &$v) {
$v *= 10;
}
foreach ($a as $v) {
}
print_r($a);salidaArray
(
[0] => 10
[1] => 20
[2] => 20
)Después del primer bucle, $v sigue siendo una referencia al último elemento. El segundo bucle asigna cada valor a $v, es decir, al último elemento, que acaba copiando al penúltimo. Es uno de los fallos más difíciles de encontrar en PHP.
Qué hacer: unset($v); justo después de un foreach por referencia, o mejor, evita las referencias: $a = array_map(fn($v) => $v * 10, $a);.
9. array_filter y el JSON que cambia de forma
<?php $numeros = [1, 0, 2, 0, 3]; echo json_encode(array_filter($numeros)), "\n"; echo json_encode(array_values(array_filter($numeros))), "\n";
salida{"0":1,"2":2,"4":3}
[1,2,3]array_filter conserva las claves, y un array cuyas claves no son 0, 1, 2… seguidas se convierte en JSON como un objeto, no como una lista. El JavaScript que esperaba un array recibe {"0":1,"2":2,"4":3}.
Qué hacer: array_values() después de filtrar si el resultado es una lista. PHP 8.1 trae array_is_list() para comprobarlo.
10. Unir arrays: + y array_merge hacen cosas distintas
<?php print_r([1, 2] + [3, 4, 5]); print_r(array_merge([5 => 'a'], [5 => 'b'])); print_r(['x' => 1] + ['x' => 2]); print_r(array_merge(['x' => 1], ['x' => 2]));
salidaArray
(
[0] => 1
[1] => 2
[2] => 5
)
Array
(
[0] => a
[1] => b
)
Array
(
[x] => 1
)
Array
(
[x] => 2
)+ une por claves y gana el de la izquierda: [1, 2] + [3, 4, 5] no es una concatenación, sino [1, 2, 5]. array_merge renumera las claves numéricas y, con claves de texto, gana el de la derecha.
Qué hacer: para concatenar listas, [...$a, ...$b] o array_merge; + solo para «valores por omisión» con claves de texto.
Texto y números
11. El 0 que significa «no encontrado»
<?php
$texto = 'php es fácil';
if (strpos($texto, 'php')) {
echo "Lo encontré\n";
} else {
echo "No está (¡pero está en la posición 0!)\n";
}
var_dump(str_contains($texto, 'php'));salidaNo está (¡pero está en la posición 0!) bool(true)
strpos devuelve la posición, que puede ser 0, o false si no encuentra nada. En un if, el 0 es falso.
Qué hacer: str_contains, str_starts_with y str_ends_with (PHP 8) para preguntar si está; strpos(...) !== false si necesitas la posición.
12. Los bytes no son letras
<?php
echo strlen('añejo'), ' ', mb_strlen('añejo'), "\n";
echo strtoupper('añejo'), ' ', mb_strtoupper('añejo'), "\n";
echo strrev('añejo'), "\n";salida6 5 AñEJO AÑEJO oje��a
Las funciones clásicas de texto trabajan con bytes y solo entienden el alfabeto inglés. Al invertir el texto byte a byte, la ñ, que ocupa dos, queda partida.
Qué hacer: las funciones mb_* con cualquier texto escrito por personas. Lo explicamos en la lección 4.
13. Sumar a un texto: la letra siguiente
<?php
$s = 'a9';
$s++;
var_dump($s);
$z = 'Az';
$z++;
var_dump($z);
var_dump(str_increment('Az'));salida Deprecated: Increment on non-numeric string is deprecated, use str_increment() instead in /curso/incremento.php on line 3 string(2) "b0" Deprecated: Increment on non-numeric string is deprecated, use str_increment() instead in /curso/incremento.php on line 7 string(2) "Ba" string(2) "Ba"
El operador ++ sobre un texto lo «incrementa» como un cuentakilómetros de letras y números, una herencia de Perl. Desde PHP 8.3 existe str_increment para hacerlo a propósito, y PHP 8.5 ya avisa de que usar ++ con textos que no son números está obsoleto: desaparecerá en una versión futura.
Qué hacer: si necesitas esa secuencia, str_increment; si no, nunca uses ++ sobre un texto.
14. Redondear la mitad
<?php var_dump(round(2.5), round(3.5), round(-2.5)); var_dump(floor(7 / 2)); var_dump((int) '1e3'); var_dump(010, 0o10, '010' == '10');
salidafloat(3) float(4) float(-3) float(3) int(1000) int(8) int(8) bool(true)
round redondea las mitades alejándose del cero (2.5 da 3, y -2.5 da -3), no al par más cercano como otros lenguajes. floor devuelve un float, no un entero. (int) entiende la notación científica. Y un número escrito con un 0 delante es octal: 010 vale 8, aunque el texto '010' se compare como diez.
Qué hacer: para un entero, intdiv o (int) floor(...); escribe los octales como 0o10 (PHP 8.1) para que se note.
15. Dos números distintos que son iguales
<?php var_dump(PHP_INT_MAX + 1); var_dump(PHP_INT_MAX + 1 == PHP_INT_MAX + 2); var_dump(0.1 + 0.2 == 0.3);
salidafloat(9.223372036854776E+18) bool(true) bool(false)
Un entero que se pasa del máximo se convierte en float y pierde precisión: a esa escala, sumar 1 o 2 da el mismo número. Y como en casi todos los lenguajes, los decimales en coma flotante son aproximados.
Qué hacer: céntimos en enteros para el dinero; abs($a - $b) < 1e-9 para comparar decimales; la extensión bcmath si necesitas precisión arbitraria.
Funciones, clases y diseño
16. ¿La aguja o el pajar primero?
<?php
foreach (['strpos', 'in_array', 'array_search', 'array_key_exists', 'array_map', 'array_filter'] as $f) {
$params = array_map(fn(ReflectionParameter $p): string => '$' . $p->getName(), (new ReflectionFunction($f))->getParameters());
printf("%-17s (%s)\n", $f, implode(', ', $params));
}salidastrpos ($haystack, $needle, $offset) in_array ($needle, $haystack, $strict) array_search ($needle, $haystack, $strict) array_key_exists ($key, $array) array_map ($callback, $array, $arrays) array_filter ($array, $callback, $mode)
Las funciones de texto ponen primero el texto donde se busca (el «pajar») y después lo buscado (la «aguja»); las de arrays, al revés. array_map recibe la función primero y array_filter, después. La salida viene de preguntarle a PHP los nombres reales de los parámetros. Es el fallo de diseño más citado del lenguaje: nadie lo planificó, y cambiarlo rompería todo.
Qué hacer: argumentos con nombre (in_array(needle: $x, haystack: $lista, strict: true)) y un editor que muestre la firma de cada función.
17. readonly impide clonar con cambios desde fuera
<?php
final class Punto
{
public function __construct(public readonly int $x, public readonly int $y) {}
}
$p = new Punto(1, 2);
$q = clone($p, ['y' => 5]);salida con error
Fatal error: Uncaught Error: Cannot modify protected(set) readonly property Punto::$y from global scope in /curso/clone_readonly.php:8
Stack trace:
#0 /curso/clone_readonly.php(8): clone(Object(Punto), Array)
#1 {main}
thrown in /curso/clone_readonly.php on line 8clone con cambios es nuevo en PHP 8.5 y parece pensado para objetos inmutables, pero una propiedad readonly solo se puede escribir desde dentro de su clase, y clone respeta esa regla.
Qué hacer: métodos with...() dentro de la clase que hagan el clone, como en la lección 5.
18. La zona horaria por omisión
<?php
echo date_default_timezone_get(), "\n";
$madrid = new DateTimeImmutable('2026-10-27 12:00', new DateTimeZone('Europe/Madrid'));
$mexico = $madrid->setTimezone(new DateTimeZone('America/Mexico_City'));
echo $madrid->format('H:i T'), ' = ', $mexico->format('H:i T'), "\n";salidaUTC 12:00 CET = 05:00 CST
Si nadie la configura, PHP usa UTC (o lo que diga date.timezone en php.ini, que cambia de un servidor a otro). Las fechas se calculan bien, pero se muestran en una hora que no es la de tus usuarios.
Qué hacer: fija date.timezone en el php.ini y trabaja con DateTimeImmutable y zonas explícitas.
Herencias del pasado
Estas ya no existen, pero siguen apareciendo en tutoriales antiguos, en respuestas de foros y, cada vez más, en código generado por IA entrenada con ese material. Si las ves, el código es antiguo y probablemente inseguro:
| Lo antiguo | Qué hacía | Eliminado en | Hoy |
|---|---|---|---|
register_globals | convertía cada parámetro de la URL en una variable | PHP 5.4 | $_GET, $_POST y validar |
magic_quotes | añadía barras a todo lo que llegaba, «para» el SQL | PHP 5.4 | consultas preparadas |
mysql_* | la extensión antigua de MySQL, sin consultas preparadas | PHP 7.0 | PDO o mysqli |
preg_replace con /e | evaluaba el reemplazo como código | PHP 7.0 | preg_replace_callback |
create_function() | creaba funciones a partir de texto, con eval por debajo | PHP 8.0 | funciones anónimas y fn |
each() | recorría arrays con un puntero interno | PHP 8.0 | foreach |
assert() con texto | ejecutaba el texto como código | PHP 8.0 | excepciones o assert con expresiones |
Ninguna de estas rarezas es un motivo para no usar PHP: todos los lenguajes tienen las suyas. Pero casi todas tienen algo en común: se evitan con ===, declare(strict_types=1) y un analizador estático como PHPStan, las tres costumbres que el curso aplica desde la primera lección. Para las que tienen que ver con la seguridad, sigue en Los fallos de seguridad típicos de PHP.
Cada rareza se ejecutó con PHP 8.5.12 en un contenedor limpio, sin php.ini (como en la imagen oficial de Docker); las salidas están copiadas de esa ejecución.
