Lección 5 de 8: clases y objetos en PHP. Las clases agrupan datos y comportamiento, y PHP moderno trae herramientas que hacen el código más corto y más seguro: promoción en el constructor, propiedades de solo lectura, enums, los property hooks y la visibilidad asimétrica de PHP 8.4, y la copia con cambios de PHP 8.5.
Una clase, con su constructor
<?php
declare(strict_types=1);
final class Cuenta
{
private float $saldo = 0;
public function __construct(
public readonly string $titular,
float $saldoInicial = 0,
) {
$this->depositar($saldoInicial);
}
public function depositar(float $importe): void
{
if ($importe < 0) {
throw new InvalidArgumentException('El importe no puede ser negativo');
}
$this->saldo += $importe;
}
public function saldo(): float
{
return $this->saldo;
}
}
$cuenta = new Cuenta('Ana', 100);
$cuenta->depositar(25.5);
echo $cuenta->titular, " tiene ", $cuenta->saldo(), "\n";salidaAna tiene 125.5
Una clase agrupa datos (propiedades) y el código que trabaja con ellos (métodos). Dentro, $this es el objeto actual y -> accede a sus miembros. Tres detalles de PHP moderno en este ejemplo:
- Promoción en el constructor:
public readonly string $titulardentro de los paréntesis del constructor declara la propiedad y la asigna en una sola línea. - Visibilidad:
privatesolo se ve desde dentro de la clase,protectedtambién desde las hijas ypublicdesde cualquier sitio. El saldo es privado para que nadie pueda ponerlo en negativo saltándosedepositar. final: nadie puede heredar de esta clase. Es buena costumbre ponerlo por omisión y quitarlo solo si hace falta.
El error que te ahorra: cambiar algo que debía ser fijo
<?php
declare(strict_types=1);
final class Factura
{
public function __construct(
public readonly string $numero,
public readonly float $total,
) {}
}
$f = new Factura('F-001', 120.0);
echo "Factura {$f->numero}: {$f->total}\n";
$f->total = 0.0;salida con errorFactura F-001: 120
Fatal error: Uncaught Error: Cannot modify readonly property Factura::$total in /curso/readonly_mal.php:14
Stack trace:
#0 {main}
thrown in /curso/readonly_mal.php on line 14Una propiedad readonly solo se puede asignar una vez, desde dentro de la clase. Para datos que no deben cambiar después de crearse, como una factura emitida, el error salta en el momento del intento, no meses después cuando alguien descubre totales a cero. Si todas las propiedades son de solo lectura, puedes escribir final readonly class Factura.
Los objetos se comparten, no se copian
Al asignar un objeto a otra variable o pasarlo a una función, no se copia: las dos variables llevan al mismo objeto. Para una copia, clone:
<?php
declare(strict_types=1);
final class Monedero
{
public function __construct(public float $saldo) {}
}
function gastar(Monedero $m, float $importe): void
{
$m->saldo -= $importe;
}
$a = new Monedero(100);
$b = $a;
gastar($b, 30);
echo "a: {$a->saldo}, b: {$b->saldo}\n";
$c = clone $a;
$c->saldo = 0;
echo "a: {$a->saldo}, c: {$c->saldo}\n";
var_dump($a == $c, $a === $b);salidaa: 70, b: 70 a: 70, c: 0 bool(false) bool(true)
La función gastar cambió el monedero de fuera, porque recibió el mismo objeto. == compara objetos por sus propiedades y === dice si son exactamente el mismo objeto.
clone con cambios (PHP 8.5) y su trampa
Con objetos de solo lectura, la forma de «cambiar» algo es crear una copia con ese cambio. PHP 8.5 permite pasar a clone las propiedades que deben cambiar:
<?php
declare(strict_types=1);
final readonly class Punto
{
public function __construct(public int $x, public int $y) {}
public function conX(int $x): static
{
return clone($this, ['x' => $x]);
}
}
$p = new Punto(1, 2);
$q = $p->conX(10);
echo "p = ({$p->x}, {$p->y}), q = ({$q->x}, {$q->y})\n";
$r = clone($p, ['y' => 99]);salida con errorp = (1, 2), q = (10, 2)
Fatal error: Uncaught Error: Cannot modify protected(set) readonly property Punto::$y from global scope in /curso/clone_with.php:18
Stack trace:
#0 /curso/clone_with.php(18): clone(Object(Punto), Array)
#1 {main}
thrown in /curso/clone_with.php on line 18Desde un método de la clase funciona. Desde fuera, no: una propiedad readonly solo se puede escribir desde dentro de su clase, aunque sea pública para leerla, y clone con cambios respeta esa regla. Por eso el patrón habitual es el que ves: métodos con...() o with...() dentro de la clase que devuelven la copia modificada.
Enums: un conjunto cerrado de valores
<?php
declare(strict_types=1);
enum Estado: string
{
case Pendiente = 'pendiente';
case Pagado = 'pagado';
case Enviado = 'enviado';
public function etiqueta(): string
{
return match ($this) {
Estado::Pendiente => 'Esperando el pago',
Estado::Pagado => 'Pagado, en preparación',
Estado::Enviado => 'En camino',
};
}
}
$e = Estado::from('pagado');
echo $e->name, " → ", $e->value, " → ", $e->etiqueta(), "\n";
var_dump(Estado::tryFrom('perdido'));
echo implode(', ', array_map(fn(Estado $c): string => $c->value, Estado::cases())), "\n";
var_dump($e === Estado::Pagado);salidaPagado → pagado → Pagado, en preparación NULL pendiente, pagado, enviado bool(true)
Un enum define todos los valores posibles de algo, como los estados de un pedido. Con : string cada caso tiene un valor para guardarlo en la base de datos; from lo convierte de vuelta y lanza un error si el texto no existe, y tryFrom devuelve null. Y si mañana añades un estado nuevo y te olvidas de él en el match, el programa se detiene con un error al llegar a ese caso, en vez de seguir en silencio; herramientas como PHPStan, que veremos en la lección 6, lo avisan incluso antes.
Property hooks y visibilidad asimétrica (PHP 8.4)
Antes, para validar una propiedad o calcularla al leerla, había que hacerla privada y escribir métodos getX() y setX(). Desde PHP 8.4, la propiedad puede llevar su propio código:
<?php
declare(strict_types=1);
final class Producto
{
public string $nombre {
set(string $valor) {
$valor = trim($valor);
if ($valor === '') {
throw new ValueError('El nombre no puede estar vacío');
}
$this->nombre = $valor;
}
}
public int $precioCentimos;
public string $precio {
get => number_format($this->precioCentimos / 100, 2) . ' €';
}
public private(set) int $vendidos = 0;
public function __construct(string $nombre, int $precioCentimos)
{
$this->nombre = $nombre;
$this->precioCentimos = $precioCentimos;
}
public function vender(): void
{
$this->vendidos++;
}
}
$p = new Producto(' Café de Veracruz ', 1250);
$p->vender();
echo "[{$p->nombre}] {$p->precio}, vendidos: {$p->vendidos}\n";
try {
$p->vendidos = 1000;
} catch (Error $e) {
echo get_class($e), ": ", $e->getMessage(), "\n";
}salida[Café de Veracruz] 12.50 €, vendidos: 1 Error: Cannot modify private(set) property Producto::$vendidos from global scope
El hook set de nombre limpia y valida cada asignación, también la del constructor. precio no guarda nada: su get lo calcula a partir de los céntimos cada vez que se lee. Y public private(set) es la visibilidad asimétrica: cualquiera puede leer vendidos, pero solo la propia clase puede cambiarlo.
Herencia, interfaces y traits
<?php
declare(strict_types=1);
interface Forma
{
public function area(): float;
}
abstract class FormaConNombre implements Forma
{
abstract public function nombre(): string;
public function describir(): string
{
return sprintf('%s de área %.2f', $this->nombre(), $this->area());
}
}
trait ConColor
{
private string $color = 'negro';
public function pintar(string $color): static
{
$this->color = $color;
return $this;
}
public function color(): string
{
return $this->color;
}
}
final class Circulo extends FormaConNombre
{
use ConColor;
public function __construct(private float $radio) {}
public function area(): float { return M_PI * $this->radio ** 2; }
public function nombre(): string { return 'Círculo'; }
}
final class Cuadrado extends FormaConNombre
{
public function __construct(private float $lado) {}
public function area(): float { return $this->lado ** 2; }
public function nombre(): string { return 'Cuadrado'; }
}
$formas = [(new Circulo(1))->pintar('rojo'), new Cuadrado(3)];
foreach ($formas as $f) {
echo $f->describir(), "\n";
}
echo $formas[0]->color(), "\n";
var_dump($formas[1] instanceof Forma);salidaCírculo de área 3.14 Cuadrado de área 9.00 rojo bool(true)
Una interfaz es un contrato: dice qué métodos debe tener una clase, sin escribirlos. Una clase abstracta puede dejar métodos sin escribir (abstract) y escribir otros que sus hijas heredan, como describir. Un trait es un trozo de código que se copia dentro de una clase con use: sirve para compartir métodos entre clases que no son de la misma familia. instanceof comprueba si un objeto cumple una clase o interfaz.
final y combinar objetos en vez de heredar. Lo veremos en práctica en el proyecto de la lección 8.Ojo con la seguridad: unserialize() con datos de fuera
serialize convierte un objeto en texto y unserialize lo reconstruye, incluida su clase. El problema es que el texto decide qué clase se crea, y al destruirse el objeto PHP ejecuta su método mágico __destruct. Si una cookie o un formulario llega a unserialize, el atacante elige qué objetos crear y con qué datos. En esta simulación, la clase solo escribe un mensaje; en una aplicación real podría borrar un archivo o escribir uno:
<?php
declare(strict_types=1);
final class ArchivoTemporal
{
public function __construct(public string $ruta) {}
public function __destruct()
{
echo "Borrando {$this->ruta}\n"; // en una aplicación real: unlink($this->ruta)
}
}
// Lo que envía el atacante en una cookie, en vez de las preferencias que esperabas
$cookie = 'O:15:"ArchivoTemporal":1:{s:4:"ruta";s:18:"/var/www/.htaccess";}';
$preferencias = unserialize($cookie);
echo "Preferencias cargadas\n";
unset($preferencias);
$seguro = unserialize($cookie, ['allowed_classes' => false]);
echo get_class($seguro), "\n";
$json = json_decode('{"tema":"oscuro"}', true, flags: JSON_THROW_ON_ERROR);
echo $json['tema'], "\n";salidaPreferencias cargadas Borrando /var/www/.htaccess __PHP_Incomplete_Class oscuro
Con la opción allowed_classes => false, unserialize no crea objetos de ninguna clase tuya: devuelve un __PHP_Incomplete_Class inofensivo. Pero la regla es más sencilla: para datos que vienen de fuera, usa JSON, que solo produce arrays, textos y números. Este tipo de fallo se llama inyección de objetos y ha afectado a WordPress, Magento y muchos plugins.
Resumen
| Para | Escribe | Recuerda |
|---|---|---|
| Propiedad y constructor a la vez | __construct(private int $x) | promoción en el constructor |
| Un dato que no cambia | public readonly | solo se escribe desde la clase |
| Copia con un cambio | clone($this, ['x' => 1]) | PHP 8.5; desde dentro si es readonly |
| Valores cerrados | enum Estado: string | from y tryFrom |
| Validar al asignar | public string $n { set { ... } } | property hooks, PHP 8.4 |
| Leer sí, escribir no | public private(set) | visibilidad asimétrica, PHP 8.4 |
| Un contrato | interface | mejor que heredar |
| Datos externos | json_decode | nunca unserialize |
Retos
Reto 1. Crea un enum Talla con los casos S, M y L, respaldado por texto, con un método precioExtra(): int (0, 2 y 5). Escribe el precio extra de la talla que llega como texto «L».
Ver la solución
<?php
declare(strict_types=1);
enum Talla: string
{
case S = 'S';
case M = 'M';
case L = 'L';
public function precioExtra(): int
{
return match ($this) {
Talla::S => 0,
Talla::M => 2,
Talla::L => 5,
};
}
}
$talla = Talla::tryFrom('L') ?? Talla::M;
echo "Talla {$talla->value}: +{$talla->precioExtra()}\n";salidaTalla L: +5
Con tryFrom y ??, un texto inesperado se convierte en una talla por omisión en vez de en un error.
Reto 2. Escribe una clase Temperatura con una propiedad celsius que no acepte valores por debajo de -273.15 (usa un hook set) y otra fahrenheit calculada.
Ver la solución
<?php
declare(strict_types=1);
final class Temperatura
{
public float $celsius {
set(float $valor) {
if ($valor < -273.15) {
throw new ValueError('Por debajo del cero absoluto');
}
$this->celsius = $valor;
}
}
public float $fahrenheit {
get => $this->celsius * 9 / 5 + 32;
}
public function __construct(float $celsius)
{
$this->celsius = $celsius;
}
}
$t = new Temperatura(25);
echo "{$t->celsius} °C = {$t->fahrenheit} °F\n";
try {
$t->celsius = -300;
} catch (ValueError $e) {
echo $e->getMessage(), "\n";
}salida25 °C = 77 °F Por debajo del cero absoluto
Como el hook valida cada asignación, ni el constructor ni ningún otro código puede dejar el objeto con una temperatura imposible.
Reto 3. ¿Qué escribe este programa? $a = new ArrayObject([1]); $b = $a; $b[] = 2; $c = clone $a; $c[] = 3; echo count($a), count($b), count($c);
Ver la solución
<?php declare(strict_types=1); $a = new ArrayObject([1]); $b = $a; $b[] = 2; $c = clone $a; $c[] = 3; echo count($a), count($b), count($c), "\n";
salida223
$a y $b son el mismo objeto, con 2 elementos; $c es una copia a la que se añadió uno más. Con arrays normales sería distinto: los arrays sí se copian al asignarlos.
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.
