Clases y objetos en PHP

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";

Probar en el editor →

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 $titular dentro de los paréntesis del constructor declara la propiedad y la asigna en una sola línea.
  • Visibilidad: private solo se ve desde dentro de la clase, protected también desde las hijas y public desde cualquier sitio. El saldo es privado para que nadie pueda ponerlo en negativo saltándose depositar.
  • 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;

Probar en el editor →

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 14

Una 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:

A la izquierda, después de b igual a a, las dos variables apuntan al mismo objeto, y cambiar el saldo por una se ve por la otra. A la derecha, con clone, b apunta a un objeto nuevo, y cambiar su saldo no afecta al de aAsignar un objeto no lo copia; clone, sí$b = $a;$a#1$b#1objeto #1saldo 70Las dos variables llevan al mismoobjeto: un cambio se ve por las dos.$b = clone $a;$a#1objeto #1saldo 100$b#2objeto #2saldo 70clone crea un objeto nuevo: cambiarel de $b no toca el de $a.

Una variable no contiene el objeto, sino una forma de llegar a él. clone crea un objeto nuevo con los mismos datos. Esquema: decodigo.com.
<?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);

Probar en el editor →

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]);

Probar en el editor →

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 18

Desde 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);

Probar en el editor →

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";
}

Probar en el editor →

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);

Probar en el editor →

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.

Interfaces antes que herencia. Las jerarquías de clases largas son difíciles de cambiar. En PHP moderno lo habitual es programar contra interfaces pequeñas, marcar las clases como 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";

Probar en el editor →

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.

Más en Los fallos de seguridad típicos de PHP.

Resumen

ParaEscribeRecuerda
Propiedad y constructor a la vez__construct(private int $x)promoción en el constructor
Un dato que no cambiapublic readonlysolo se escribe desde la clase
Copia con un cambioclone($this, ['x' => 1])PHP 8.5; desde dentro si es readonly
Valores cerradosenum Estado: stringfrom y tryFrom
Validar al asignarpublic string $n { set { ... } }property hooks, PHP 8.4
Leer sí, escribir nopublic private(set)visibilidad asimétrica, PHP 8.4
Un contratointerfacemejor que heredar
Datos externosjson_decodenunca 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";

Probar en el editor →

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";
}

Probar en el editor →

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";

Probar en el editor →

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.