PHP 8.6 se publicará el 19 de noviembre de 2026. En el momento de escribir esta entrada la versión está en beta 3, el feature freeze es el 22 de septiembre y la primera release candidate llega 2 días después, así que la lista de novedades ya está prácticamente cerrada.
Si vienes siguiendo la serie, ya sabes cómo funciona esto: cada noviembre PHP suma un puñado de características que cambian el día a día. PHP 8.5 nos dejó el operador pipe y la extensión URI, y PHP 8.6 va justo por detrás a completar ese trabajo. La característica estrella es la aplicación parcial de funciones, que es precisamente la pieza que le faltaba al operador pipe para resultar útil en código real.
Vamos a verlo todo con ejemplos, y antes de empezar vamos a montar un proyecto de pruebas con PHP 8.6 para que puedas ejecutar cada uno de ellos mientras lees.
Compatibilidad y requisitos
Antes de tocar código, conviene tener claro el terreno:
Fecha de salida: 19 de noviembre de 2026 (GA). Release managers: Daniel Scherzer, Matteo Beccati y Joe Ferguson.
Soporte activo: aproximadamente hasta noviembre de 2028, y parches de seguridad hasta noviembre de 2029.
Es una versión menor, así que la actualización desde PHP 8.5 no debería romper nada grave. Lo que sí vas a ver son avisos de deprecación nuevos, que repaso al final del artículo.
Laravel: Laravel 13 declara soporte de PHP 8.3 a PHP 8.5. El soporte oficial para 8.6 llegará en una versión menor del framework, como ha pasado en años anteriores. No actualices el runtime de producción el mismo día del lanzamiento.
Cambios de configuración: los valores por defecto de sesión cambian en instalaciones nuevas. Si usas sesiones nativas de PHP y no las de Laravel, léete ese apartado con calma.
Crea tu proyecto de pruebas en 5 minutos
Leer las novedades está bien, pero donde se entienden de verdad es ejecutándolas. Vamos a montar un proyecto mínimo con PHP 8.6 donde podrás probar cada ejemplo de este artículo sin tocar el PHP que tienes instalado en tu máquina.
Solo necesitas Docker. La imagen oficial php:8.6-rc apunta siempre a la última versión previa publicada (ahora mismo una beta, a partir del 24 de septiembre la RC1), así que no tienes que compilar nada.
1. Crea la carpeta del proyecto
mkdir php86-playground && cd php86-playground
mkdir examples2. Crea el Dockerfile
En la raíz del proyecto, crea un fichero Dockerfile con este contenido:
FROM php:8.6-rc-cli
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
RUN apt-get update \
&& apt-get install -y git unzip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /appPartimos de la imagen de PHP 8.6 y le copiamos Composer desde su imagen oficial, así lo tienes disponible por si quieres instalar paquetes para tus pruebas.
3. Crea el compose.yaml
services:
php:
build: .
volumes:
- .:/appEl volumen monta la carpeta del proyecto dentro del contenedor, así que cualquier fichero que edites en tu editor se ejecuta al momento, sin reconstruir nada.
Antes de seguir, comprueba que la estructura es exactamente esta. Todos los comandos se ejecutan desde la carpeta php86-playground:
php86-playground/
├── Dockerfile
├── compose.yaml
└── examples/Si al ejecutar cualquier comando docker compose ves el error no configuration file provided: not found, es que Docker no encuentra el compose.yaml: o no estás en la carpeta del proyecto o el fichero se ha guardado con otro nombre (tu editor puede haberle añadido .txt sin que lo veas). Revísalo con ls -la.
4. Construye la imagen y comprueba la versión
docker compose build --pull
docker compose run --rm php php -vDeberías ver algo como PHP 8.6.0beta3 (cli). Si ves una versión 8.6, ya está: tienes PHP 8.6 funcionando.
5. Tu primer script con PHP 8.6
Crea el fichero examples/01-hello-86.php:
<?php
$slug = 'Hola PHP 8.6'
|> strtolower(...)
|> str_replace(' ', '-', ?);
echo $slug . PHP_EOL;
echo clamp(150, min: 0, max: 100) . PHP_EOL;Y ejecútalo:
docker compose run --rm php php examples/01-hello-86.phpSi ves hola-php-8.6 y 100, acabas de usar la aplicación parcial de funciones y clamp(), 2 cosas que en PHP 8.5 darían un error de sintaxis y una función inexistente.
A partir de aquí, cada vez que veas un ejemplo en el artículo, cópialo a un fichero dentro de examples/ y ejecútalo igual. Para lanzarlos todos de golpe:
for f in examples/*.php; do echo "== $f"; docker compose run --rm php php "$f"; done6. Mantén la versión al día
Hasta el 19 de noviembre saldrán nuevas betas y release candidates. Para actualizar tu proyecto a la última:
docker compose build --pullTen en cuenta que hasta la versión final algún detalle puede cambiar. Si un ejemplo falla, lo primero es comprobar con php -v qué versión estás ejecutando.
¿Y en un proyecto que ya tienes?
Si quieres ver cómo se comporta tu aplicación real, no hace falta cambiar nada de tu entorno. Desde la raíz del proyecto:
docker run --rm -it -v "$PWD":/app -w /app php:8.6-rc-cli php vendor/bin/phpunitAsí ejecutas tus tests con PHP 8.6 y ves de un vistazo qué deprecaciones aparecen. Es la forma más rápida de saber cuánto trabajo te espera antes de noviembre.
Aplicación parcial de funciones
Esta es la novedad grande. La aplicación parcial de funciones (PFA, por partial function application) te permite llamar a una función dejando algunos argumentos ya fijados, y recibir a cambio un closure que acepta los que faltan.
El marcador es una interrogación:
<?php
$makeSlug = str_replace(' ', '-', ?);
echo $makeSlug('Hola Mundo'); // Hola-MundoHasta ahora, para conseguir lo mismo tenías que escribir un closure a mano:
<?php
$makeSlug = fn (string $subject): string => str_replace(' ', '-', $subject);La diferencia no es solo de caracteres. El closure que genera PFA conserva los nombres de los parámetros, sus tipos y sus valores por defecto de la función original, cosa que un fn escrito a mano pierde. Y los argumentos que fijas se evalúan en el momento de crear el parcial, no en el momento de llamarlo.
Donde más se nota es en array_map y compañía:
<?php
$titles = ['Laravel', 'Symfony', 'Tempest'];
$lowercase = array_map(strtolower(?), $titles);PFA con el operador pipe de PHP 8.5
Aquí está la combinación que hace que esta versión merezca la pena. El operador pipe que llegó en PHP 8.5 exige un callable de exactamente un parámetro, y eso dejaba fuera a casi todas las funciones nativas de PHP, que suelen tener 2 o 3 parámetros. Con PFA ya no hay problema:
<?php
$result = 'Hola Mundo'
|> str_replace(' ', '-', ?)
|> strtolower(...);
// hola-mundoFíjate en la diferencia entre ? y .... La interrogación marca un hueco concreto que rellenarás después, mientras que ... es la sintaxis de first class callable que ya existe desde PHP 8.1 y que pasa la función entera como callable.
Un detalle que conviene recordar: una RFC posterior dejó fijado que cada ? se convierte en un parámetro obligatorio del closure resultante, aunque en la función original ese parámetro fuese opcional.
<?php
$padLeft = str_pad(?, 10, '0', STR_PAD_LEFT);
echo $padLeft('42'); // 0000000042Esta propuesta ya se intentó en 2021 y se rechazó. La segunda versión se aprobó por 33 votos a favor y ninguno en contra, lo que da una idea de lo asentada que está la idea ahora.
Pruébalo en tu playground: crea examples/02-pfa.php con un array_map que use str_pad(?, 5, '0', STR_PAD_LEFT) sobre ['1', '22', '333'] y comprueba que obtienes ['00001', '00022', '00333'].
La función clamp()
Cuántas veces has escrito esto:
<?php
$page = min(max($page, 1), $totalPages);Funciona, pero es fácil equivocarse y poner el min y el max al revés. PHP 8.6 incorpora clamp() de forma nativa:
<?php
clamp(10, min: 0, max: 100); // 10
clamp(101, min: 0, max: 100); // 100
clamp(-1, min: 0, max: 100); // 0Lo interesante es que no se limita a números. Funciona con cualquier tipo comparable, incluidas cadenas:
<?php
clamp('y', 'x', 'z'); // y
clamp('a', 'x', 'z'); // xY con objetos de fecha:
<?php
clamp(
value: new DateTimeImmutable('2025-01-01'),
min: new DateTimeImmutable('2026-01-01'),
max: new DateTimeImmutable('2026-12-31'),
); // DateTimeImmutable('2026-01-01')Si le pasas un $min mayor que el $max, lanza un ValueError.
Pruébalo en tu playground: llama a clamp(5, min: 10, max: 0) dentro de un try/catch (ValueError $e) y muestra el mensaje con echo $e->getMessage();.
La clase Time\Duration
PHP 8.6 añade Time\Duration, una clase final readonly que representa una duración con precisión de nanosegundos. Tiene métodos de fábrica para cada unidad, operaciones aritméticas y soporte de comparación:
<?php
use Time\Duration;
$oneSecond = Duration::fromSeconds(1);
$halfSecond = $oneSecond->divideBy(2);
$total = $oneSecond->add($halfSecond);El caso de uso que más me gusta es el backoff exponencial de los reintentos, algo que todos hemos escrito con enteros sueltos y comentarios explicando si son segundos o milisegundos:
<?php
use Time\Duration;
$attempt = 5;
$delay = Duration::fromMilliseconds(100)->multiplyBy(2 ** $attempt);Las comparaciones funcionan con los operadores de siempre:
<?php
if ($durationA < $durationB) {
// ...
}También se pueden construir desde una cadena de duración ISO 8601. La motivación principal de la RFC es interna: dar a las funciones del core y a la nueva API de polling un tipo compartido que aceptar en lugar de enteros y floats ambiguos.
Pruébalo en tu playground: calcula la espera de los intentos 1 a 5 con el ejemplo del backoff y muéstralas en un bucle con var_dump() para ver cómo se representa una Duration.
Propiedades readonly con valor por defecto
Hasta PHP 8.5, poner un valor por defecto en una propiedad readonly era un error de compilación. Tenía su lógica: una propiedad de solo lectura con un valor fijo es básicamente una constante.
Esa decisión se tomó antes de que existieran los property hooks de PHP 8.4, que permiten declarar propiedades en interfaces:
<?php
interface Migration
{
public string $name { get; }
public function up(): void;
}Con ese contrato de por medio, un valor por defecto sí tiene sentido, y PHP 8.6 lo permite:
<?php
final readonly class CreateBooksTable implements Migration
{
public string $name = '2026_01_01_create_books_table';
public function up(): void
{
// ...
}
}La RFC se limita a levantar la restricción. La semántica de readonly no cambia: la propiedad sigue sin poder reasignarse después de inicializarse.
Pruébalo en tu playground: copia la clase CreateBooksTable, intenta hacer $migration->name = 'otra'; y comprueba el Error que lanza PHP. Después ejecuta el mismo fichero con PHP 8.5 (docker run --rm -v "$PWD":/app -w /app php:8.5-cli php examples/your-file.php) y verás que ni siquiera compila.
DocComments en parámetros de función
Los bloques de documentación ya pueden ir pegados al parámetro que describen, y ReflectionParameter::getDocComment() los devuelve.
Antes escribías esto:
<?php
/** @param Book[] $books */
function store(array $books): void
{
// ...
}Ahora puedes escribir esto:
<?php
function search(
/** Términos a buscar en la base de datos */
string $query,
/** Número máximo de resultados a devolver */
int $limit = 10,
): array {
// ...
}Te ahorras repetir el nombre del parámetro en el tag @param, y los analizadores estáticos y el IDE pueden leer el comentario directamente del parámetro. Ojo con la colocación: si pones el comentario detrás del parámetro, debe ir antes de la coma que lo separa del siguiente.
Enum SortDirection
PHP 8.6 incorpora un enum global para representar direcciones de ordenación:
<?php
enum SortDirection
{
case Ascending;
case Descending;
}De momento nada del core lo acepta. La idea de la RFC es ofrecer un tipo compartido para que cada librería deje de definir el suyo, y el ejemplo que pone es justo el de los query builders:
<?php
$query->orderBy('created_at', SortDirection::Descending);Es un cambio pequeño con una intención clara: acabar con las constantes enteras tipo SORT_ASC y SORT_DESC, que no aportan ninguna seguridad de tipos. Funciones como array_multisort() o scandir() todavía no lo soportan, pero el plan es que lo hagan más adelante.
Los enums ya pueden implementar __debugInfo()
Cuando los enums llegaron en PHP 8.1 se les prohibió definir casi todos los métodos mágicos. PHP 8.6 levanta la restricción para __debugInfo(), porque no necesita estado:
<?php
enum Status: int
{
case Ok = 200;
case NotFound = 404;
public function __debugInfo(): array
{
return [__CLASS__ . '::' . $this->name . ' = ' . $this->value];
}
}
var_dump(Status::Ok);
// enum(Status::Ok) (1) {
// [0]=>
// string(16) "Status::Ok = 200"
// }Manejo de errores en streams
Los streams reciben por fin un modelo de errores coherente. Hay una nueva opción de contexto error_mode que te deja elegir entre los warnings de siempre, excepciones o silencio, y stream_last_errors() devuelve objetos StreamError estructurados de la última operación:
<?php
$context = stream_context_create([
'stream' => ['error_mode' => StreamErrorMode::Exception],
]);
try {
$stream = fopen('/nonexistent/file.txt', 'r', false, $context);
} catch (StreamException $e) {
foreach ($e->getErrors() as $error) {
echo $error->code->name . ': ' . $error->message;
}
}La RFC define más de 50 códigos de error semánticos en un enum StreamErrorCode. Si alguna vez has intentado averiguar por qué falló un fopen() leyendo un warning, sabes lo que esto vale.
Nueva API de polling
El namespace Io\Poll da a PHP una interfaz unificada sobre los mecanismos de polling de cada plataforma: epoll en Linux, kqueue en BSD y macOS, event ports en Solaris y WSAPoll en Windows. Sustituye a stream_select() para quien construye event loops o runtimes asíncronos:
<?php
use Io\Poll\Context;
use Io\Poll\Event;
use Io\Poll\StreamPollHandle;
use Time\Duration;
$poll = new Context();
$server = stream_socket_server('tcp://0.0.0.0:8080');
stream_set_blocking($server, false);
$poll->add(new StreamPollHandle($server), [Event::Read], ['type' => 'server']);
while (true) {
foreach ($poll->wait(Duration::fromSeconds(1)) as $watcher) {
if ($watcher->hasTriggered(Event::Read)) {
// aceptar la conexión
}
}
}Conviene aclarar 2 cosas. La primera es que esto no añade funcionalidad asíncrona nueva a PHP, solo una forma mejor de interactuar con la que ya existe, y con mejor rendimiento que stream_select() a cierta escala. La segunda es que no trae event loop incorporado, así que la abstracción de alto nivel sigue siendo trabajo de librerías como ReactPHP o Amp. La motivación principal de la RFC es interna, para el manejo de señales y mejoras en FPM.
La extensión URI gana un builder
La extensión URI que llegó en PHP 8.5 se completa con clases builder, para que puedas montar una URI sin crear objetos intermedios por cada componente:
<?php
$uri = new Uri\Rfc3986\UriBuilder()
->setScheme('https')
->setHost('example.com')
->setPath('/foo/bar')
->build();La RFC de seguimiento añade además los métodos getUriType() y getHostType(), y funciones de codificación porcentual para componentes individuales de la URI.
Valores por defecto de sesión más seguros
3 ajustes de php.ini cambian su valor por defecto en instalaciones nuevas:
Ajuste | Antes | Ahora |
|---|---|---|
|
|
|
|
|
|
| sin valor |
|
Laravel gestiona sus propias cookies de sesión, así que la mayoría de aplicaciones no se ven afectadas. Si usas sesiones nativas de PHP, revisa estos 3 casos:
Con
use_strict_modeactivo, una aplicación que entregue un ID de sesión generado fuera (un traspaso entre subdominios, por ejemplo) verá ese ID rechazado. La solución es escribir y cerrar la sesión consession_write_close()en el origen antes de pasar el ID.Con
cookie_httponlyactivo, el valor de la cookie de sesión deja de ser legible desdedocument.cookie. Si tu JavaScript lo leía para meterlo en una cabecera, necesitas un token aparte.Con
SameSite=Lax, la cookie no viaja en peticiones POST entre sitios distintos. Los flujos SAML iniciados por el SP y los formularios cross-origin heredados son los candidatos a romperse.
Novedades en Reflection
ReflectionProperty incorpora isReadable() e isWritable(), que tienen mucho sentido ahora que existen los property hooks y la visibilidad asimétrica:
<?php
final class Book
{
private(set) public string $title;
}
$property = new ReflectionProperty(Book::class, 'title');
$property->isWritable(scope: Book::class); // true
$property->isWritable(scope: null); // falseEl parámetro $scope es la clave: determina desde qué ámbito se pregunta. Y si pasas además un objeto como segundo parámetro, la API puede decirte si una propiedad readonly ya ha sido inicializada, que es lo que en la práctica determina si todavía se puede escribir.
Deprecaciones que debes revisar
Como toda versión menor, PHP 8.6 trae un buen paquete de deprecaciones. No rompen nada hoy, pero lo harán en PHP 9. Estas son las que más probablemente te toquen:
Funciones antiguas con alias moderno:
is_double(), usais_float()is_integer()yis_long(), usais_int()doubleval(), usafloatval()spl_object_hash(), usaspl_object_id()spl_classes(), usanew ReflectionExtension('spl')->getClassNames()strcoll()ymetaphone()El flag
SORT_LOCALE_STRINGen las funciones de ordenación
Comportamientos confusos del lenguaje:
Devolver desde un bloque
finally. Ha sido siempre una fuente de bugs sutiles, porque elreturndelfinallypisa al deltry.
<?php
function getConfig(): array
{
try {
return loadConfig();
} finally {
return []; // deprecado en PHP 8.6
}
}Devolver valores desde constructores y destructores. Nunca tuvo sentido, porque no hay contexto donde puedas recoger ese valor.
Pasar objetos a funciones que esperan arrays, como
array_walk(). La implementación interna puede provocar corrupción de memoria.
Identificadores reservados para el futuro. Esto es lo que más me interesa a mí, porque deja ver por dónde va el lenguaje:
letdeja de poder usarse como identificador, para poder convertirlo en palabra clave más adelante.istampoco, con la vista puesta en combinarlo conmatch.namespacedeja de poder usarse como nombre de constante de clase, para reservar una posible pseudoconstante::namespaceanáloga a::class.readonlydeja de poder usarse como nombre de función. Se permitía por un hack en el lexer que existía porque WordPress tenía una función con ese nombre._como nombre de constante, continuando la deprecación que PHP 8.4 ya aplicó a los nombres de clase.
Otras que pueden afectarte si usas SPL o mysqli: varios métodos de ArrayIterator (asort(), ksort(), getFlags(), serialize() y compañía), los métodos de CSV de SplFileObject (fgetcsv(), fputcsv(), setCsvControl(), getCsvControl()), mysqli::stmt_init() y mysqli_get_charset().
La forma sana de afrontar esto es activar el reporte de deprecaciones en tu entorno local y en CI antes de noviembre. Si dejas que se acumulen hasta PHP 9, la migración duele.
Mi valoración
PHP 8.6 es una versión de las que cierran cosas que estaban a medias. No cambia la forma de pensar el lenguaje, como sí hizo PHP 8.0, y tampoco lo pretende.
El operador pipe de PHP 8.5 era llamativo en las demos y poco práctico en el código real porque casi ninguna función nativa tiene un solo parámetro. Con la aplicación parcial de funciones eso se arregla, y las 2 características juntas sí cambian cómo escribes transformaciones de datos. Lo mismo pasa con readonly y los valores por defecto: es una restricción que sobraba desde que los property hooks llegaron en PHP 8.4.
De lo demás, clamp() es la típica función que llevas escribiendo a mano toda la vida, y Time\Duration va a envejecer bien conforme el core y las librerías empiecen a aceptarla en lugar de enteros ambiguos.
Si quieres repasar cómo hemos llegado hasta aquí, tienes la serie completa:
Y si quieres aprender PHP 8 a fondo en lugar de ir versión a versión, la ruta de aprendizaje de PHP 8 recoge 12 cursos con algo más de 21 horas de contenido. Para ver cómo encaja todo esto con el framework, échale un ojo a Laravel 13: novedades, fecha de lanzamiento y requisitos.
Hasta aquí este repaso a PHP 8.6. Nos vemos en noviembre, cuando toque comprobar si la lista final se parece a esta.