Lección 4 de 8: funciones, cadenas y errores en PHP. Las funciones son la forma de no repetir código, y en PHP moderno llevan tipos, argumentos con nombre y funciones anónimas. Esta lección las cubre, junto con el texto, que en español da sorpresas por culpa de las tildes, y los errores: cómo se lanzan, cómo se capturan y cómo evitar que revelen secretos.
Funciones con tipos
<?php
declare(strict_types=1);
function precioFinal(float $precio, float $iva = 0.16, int $descuento = 0): float
{
$conDescuento = $precio * (100 - $descuento) / 100;
return round($conDescuento * (1 + $iva), 2);
}
echo precioFinal(100), "\n";
echo precioFinal(100, 0.21), "\n";
echo precioFinal(100, descuento: 10), "\n";
echo precioFinal(descuento: 50, precio: 80), "\n";salida116 121 104.4 46.4
Los parámetros pueden tener un valor por omisión, y desde PHP 8 se pueden pasar por nombre: descuento: 10 se salta el IVA sin tener que repetir su valor, y con nombres el orden da igual. Es muy útil en funciones con muchos parámetros opcionales, y hace que la llamada se entienda sola.
El error que te ahorra: un nombre de argumento mal escrito
<?php
declare(strict_types=1);
function saludar(string $nombre, string $saludo = 'Hola'): string
{
return "$saludo, $nombre";
}
echo saludar(nombe: 'Ana'), "\n";salida con error
Fatal error: Uncaught Error: Unknown named parameter $nombe in /curso/nombre_mal.php:9
Stack trace:
#0 {main}
thrown in /curso/nombre_mal.php on line 9Con argumentos por posición, un error así pasaría desapercibido. Con nombre, PHP comprueba que exista el parámetro y se detiene. Eso sí: al usar argumentos con nombre, los nombres de los parámetros pasan a formar parte de la función, y cambiarlos rompe a quien la llama.
Más tipos: uniones, void, never y variádicas
<?php
declare(strict_types=1);
function describir(int|string $id): string
{
return is_int($id) ? "número $id" : "código $id";
}
function sumar(int ...$numeros): int
{
return array_sum($numeros);
}
function registrar(string $mensaje): void
{
echo "[registro] $mensaje\n";
}
function terminar(string $motivo): never
{
throw new RuntimeException($motivo);
}
echo describir(42), " / ", describir('A-7'), "\n";
echo sumar(1, 2, 3), " / ", sumar(...[10, 20]), "\n";
registrar('todo bien');salidanúmero 42 / código A-7 6 / 30 [registro] todo bien
int|string acepta cualquiera de los dos tipos. ...$numeros recoge todos los argumentos que lleguen en un array, y ... en la llamada hace lo contrario: reparte un array en argumentos. void indica que la función no devuelve nada, y never, que nunca termina normalmente: siempre lanza una excepción o sale del programa.
Closures y funciones flecha
Una función también es un valor que se puede guardar en una variable o pasar a otra función, como hicimos con array_map en la lección 3. Hay dos formas de escribir una función anónima, y se diferencian en cómo ven las variables de fuera:
<?php
declare(strict_types=1);
$iva = 0.16;
$conUse = function (float $p) use ($iva): float {
return $p * (1 + $iva);
};
$flecha = fn(float $p): float => $p * (1 + $iva);
echo $conUse(100), " ", $flecha(100), "\n";
$iva = 0.21;
echo $conUse(100), " ", $flecha(100), "\n";
function contador(): Closure
{
$n = 0;
return function () use (&$n): int {
return ++$n;
};
}
$siguiente = contador();
echo $siguiente(), $siguiente(), $siguiente(), "\n";salida116 116 116 116 123
Una function anónima no ve las variables de fuera salvo que las pidas con use, y las copia en el momento de crearse: por eso las dos siguen calculando con el 16 % aunque luego cambiemos $iva. La función flecha, fn, captura sola lo que usa, también por valor, y solo admite una expresión. Con use (&$n) la closure comparte la variable en vez de copiarla, y así contador recuerda la cuenta entre llamadas.
Cadenas de texto y UTF-8
En PHP un string es una secuencia de bytes, no de letras. Mientras el texto sea inglés da igual, pero en español, con tildes y eñes, que en UTF-8 ocupan dos bytes, no:
<?php
declare(strict_types=1);
$palabra = 'Ñandú';
echo strlen($palabra), " bytes, ", mb_strlen($palabra), " letras\n";
echo strtoupper('ñandú'), " / ", mb_strtoupper('ñandú'), "\n";
echo substr($palabra, 0, 1), " / ", mb_substr($palabra, 0, 1), "\n";
var_dump(substr($palabra, 0, 1) === "\xC3");
echo ucfirst('élite'), " / ", mb_ucfirst('élite'), "\n";salida7 bytes, 5 letras ñANDú / ÑANDÚ � / Ñ bool(true) élite / Élite
Las funciones clásicas (strlen, strtoupper, substr, ucfirst) trabajan con bytes y solo entienden el alfabeto inglés: strtoupper deja la ñ y la ú como estaban, y substr corta la Ñ por la mitad y devuelve medio carácter, que se ve como un símbolo roto. Sus versiones con mb_ delante (de multibyte) trabajan con letras. Con texto que escriben personas, usa siempre las mb_.
Las funciones de texto que más usarás
<?php
declare(strict_types=1);
$correo = ' Ana.Lopez@Ejemplo.com ';
$limpio = mb_strtolower(trim($correo));
echo "[$limpio]\n";
var_dump(str_contains($limpio, '@'));
var_dump(str_starts_with($limpio, 'ana'));
var_dump(str_ends_with($limpio, '.org'));
[$usuario, $dominio] = explode('@', $limpio);
echo "$usuario en $dominio\n";
echo implode(' | ', ['uno', 'dos', 'tres']), "\n";
echo str_replace(['.', '@'], [' punto ', ' arroba '], $limpio), "\n";
printf("%-10s %8.2f\n", 'Total:', 1234.5);
echo number_format(1234567.891, 2), " / ", number_format(1234567.891, 2, ',', '.'), "\n";
echo str_pad('7', 3, '0', STR_PAD_LEFT), "\n";salida[ana.lopez@ejemplo.com] bool(true) bool(true) bool(false) ana.lopez en ejemplo.com uno | dos | tres ana punto lopez arroba ejemplo punto com Total: 1234.50 1,234,567.89 / 1.234.567,89 007
str_contains, str_starts_with y str_ends_with llegaron en PHP 8; en código antiguo verás strpos(...) !== false, que hace lo mismo con la trampa del 0 de la lección anterior. printf y sprintf dan formato con plantillas: %-10s es un texto alineado a la izquierda en 10 posiciones y %8.2f un decimal con dos cifras en 8. number_format pone los separadores de miles, y sus dos últimos argumentos permiten usar la coma decimal.
Textos largos: heredoc y nowdoc
<?php
declare(strict_types=1);
$nombre = 'Ana';
$total = 42.5;
$correo = <<<TXT
Hola, $nombre:
Tu pedido suma {$total} euros.
Gracias por tu compra.
TXT;
$plantilla = <<<'TXT'
Aquí $nombre se queda tal cual.
TXT;
echo $correo, "\n", $plantilla, "\n";salidaHola, Ana: Tu pedido suma 42.5 euros. Gracias por tu compra. Aquí $nombre se queda tal cual.
Un heredoc (<<<TXT) es un texto de varias líneas que sustituye variables como las comillas dobles; un nowdoc (<<<'TXT', con comillas) no sustituye nada, como las simples. La sangría de la línea de cierre se quita de todas las líneas, así que el texto puede ir sangrado junto al código.
Errores y excepciones
Cuando algo sale mal, PHP lanza un objeto que describe el problema, y el programa se detiene salvo que alguien lo capture. Todo lo que se puede lanzar desciende de Throwable, que tiene dos ramas:
<?php
declare(strict_types=1);
function dividir(int $a, int $b): float
{
if ($b === 0) {
throw new InvalidArgumentException('No se puede dividir entre cero');
}
return $a / $b;
}
foreach ([[10, 4], [7, 0]] as [$a, $b]) {
try {
$resultado = dividir($a, $b);
echo "$a / $b = $resultado\n";
} catch (InvalidArgumentException $e) {
echo "Error: ", $e->getMessage(), "\n";
} finally {
echo "(cálculo terminado)\n";
}
}
try {
echo intdiv(1, 0);
} catch (Exception $e) {
echo "Esto no se ejecuta\n";
} catch (Error $e) {
echo get_class($e), ": ", $e->getMessage(), "\n";
}salida10 / 4 = 2.5 (cálculo terminado) Error: No se puede dividir entre cero (cálculo terminado) DivisionByZeroError: Division by zero
throw lanza, try marca el código vigilado, catch atrapa un tipo concreto y finally se ejecuta siempre, haya error o no. Un catch (Exception $e) no atrapa el DivisionByZeroError, porque es de la otra rama: hace falta catch (Error $e) o catch (Throwable $e).
Tus propias excepciones
<?php
declare(strict_types=1);
final class SaldoInsuficiente extends RuntimeException
{
public function __construct(public readonly float $falta)
{
parent::__construct(sprintf('Faltan %.2f para completar el pago', $falta));
}
}
function pagar(float $saldo, float $importe): float
{
if ($importe > $saldo) {
throw new SaldoInsuficiente($importe - $saldo);
}
return $saldo - $importe;
}
try {
pagar(50, 80.5);
} catch (SaldoInsuficiente $e) {
echo $e->getMessage(), "\n";
echo "Dato para el programa: ", $e->falta, "\n";
}salidaFaltan 30.50 para completar el pago Dato para el programa: 30.5
Una excepción propia es una clase que hereda de Exception o de una de sus hijas; las clases las vemos en la lección 5. Tener un tipo propio permite capturar solo ese problema y llevar datos útiles, como cuánto falta.
Los avisos no son excepciones
En la lección 1 vimos que una variable mal escrita produce un Warning y el programa sigue. Los avisos no se pueden capturar con try, pero sí convertir en excepciones con un manejador de errores, una costumbre muy recomendable en aplicaciones nuevas:
<?php
declare(strict_types=1);
set_error_handler(function (int $nivel, string $mensaje, string $archivo, int $linea): never {
throw new ErrorException($mensaje, 0, $nivel, $archivo, $linea);
});
try {
$config = ['idioma' => 'es'];
echo $config['tema'];
} catch (ErrorException $e) {
echo "Capturado: ", $e->getMessage(), " (línea ", $e->getLine(), ")\n";
}salidaCapturado: Undefined array key "tema" (línea 10)
Con este manejador, cualquier aviso detiene el programa en vez de dejarlo seguir con un valor vacío. Los frameworks como Laravel o Symfony lo hacen por ti.
Ojo con la seguridad: enseñar los errores con datos sensibles
<?php
declare(strict_types=1);
function conectar(string $usuario, string $clave): never
{
throw new RuntimeException('No se pudo conectar a la base de datos');
}
conectar('app', 'Secreto-123');salida con error
Fatal error: Uncaught RuntimeException: No se pudo conectar a la base de datos in /curso/traza.php:6
Stack trace:
#0 /curso/traza.php(9): conectar('app', 'Secreto-123')
#1 {main}
thrown in /curso/traza.php on line 6La traza de una excepción incluye los argumentos de cada llamada, y aquí está la contraseña de la base de datos. Si el servidor tiene display_errors activado, cualquiera que provoque ese error la verá en la página, y aunque no lo tenga, acabará en los registros. Hay dos defensas. La primera, de configuración: en producción, display_errors = Off y un mensaje genérico para el visitante. La segunda, en el código: desde PHP 8.2, el atributo #[\SensitiveParameter] oculta ese argumento en todas las trazas:
<?php
declare(strict_types=1);
function conectar(string $usuario, #[\SensitiveParameter] string $clave): never
{
throw new RuntimeException('No se pudo conectar a la base de datos');
}
conectar('app', 'Secreto-123');salida con error
Fatal error: Uncaught RuntimeException: No se pudo conectar a la base de datos in /curso/traza_segura.php:6
Stack trace:
#0 /curso/traza_segura.php(9): conectar('app', Object(SensitiveParameterValue))
#1 {main}
thrown in /curso/traza_segura.php on line 6Úsalo en cualquier parámetro que reciba contraseñas, claves de API o tokens.
Resumen
| Para | Escribe | Recuerda |
|---|---|---|
| Saltarte un opcional | f(100, descuento: 10) | los nombres de parámetros pasan a importar |
| Varios tipos | int|string $id | ?string es string|null |
| Muchos argumentos | function f(int ...$n) | llegan como array |
| Función corta | fn($x) => $x * 2 | captura sola, por valor |
| Contar letras | mb_strlen | strlen cuenta bytes |
| Mayúsculas con tildes | mb_strtoupper | strtoupper solo sabe inglés |
| Atrapar cualquier fallo | catch (Throwable $e) | Exception no atrapa Error |
| Ocultar secretos | #[\SensitiveParameter] | y display_errors = Off |
Retos
Reto 1. Escribe iniciales(string $nombre): string que devuelva las iniciales en mayúscula: «álvaro núñez» debe dar «ÁN».
Ver la solución
<?php
declare(strict_types=1);
function iniciales(string $nombre): string
{
$palabras = explode(' ', trim($nombre));
return implode('', array_map(fn(string $p): string => mb_strtoupper(mb_substr($p, 0, 1)), $palabras));
}
echo iniciales('álvaro núñez'), "\n";
echo iniciales('Eva'), "\n";salidaÁN E
Con substr y strtoupper habrías obtenido medio carácter de la «á» y la «n» sin cambiar. Es el error más típico al tratar nombres en español.
Reto 2. Escribe edad(string $texto): int que convierta el texto en un entero entre 0 y 130, y lance una InvalidArgumentException si no lo es. Pruébala con «42», «-5» y «cuarenta».
Ver la solución
<?php
declare(strict_types=1);
function edad(string $texto): int
{
$n = filter_var($texto, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0, 'max_range' => 130]]);
if ($n === false) {
throw new InvalidArgumentException("«{$texto}» no es una edad válida");
}
return $n;
}
foreach (['42', '-5', 'cuarenta'] as $t) {
try {
echo edad($t), "\n";
} catch (InvalidArgumentException $e) {
echo $e->getMessage(), "\n";
}
}salida42 «-5» no es una edad válida «cuarenta» no es una edad válida
filter_var admite un rango con min_range y max_range, así que valida el formato y los límites de una vez.
Reto 3. ¿Qué escribe este programa? $x = 1; $f = fn() => $x + 1; $g = function () use ($x) { return $x + 1; }; $x = 10; echo $f(), $g();
Ver la solución
<?php
declare(strict_types=1);
$x = 1;
$f = fn() => $x + 1;
$g = function () use ($x) { return $x + 1; };
$x = 10;
echo $f(), $g(), "\n";salida22
Las dos capturan $x por valor en el momento de crearse, cuando valía 1. Por eso las dos devuelven 2, aunque luego $x valga 10.
Todo el código de esta lección se ejecutó con PHP 8.5.12 en un contenedor limpio antes de publicarla; los mensajes de error y las salidas están copiados de esa ejecución. ¿Algo no te funciona? Revisa que tengas PHP 8.5: php -v.
