Módulo 11: POO: fundamentos

Objetivos del módulo

  • Entender qué problema resuelve la Programación Orientada a Objetos
  • Distinguir clase y objeto, propiedades y métodos, y usar $this
  • Aplicar la visibilidad public, protected y private para encapsular
  • Escribir constructores, usar constructor property promotion y propiedades tipadas
  • Crear propiedades y clases readonly y encadenar new sin paréntesis (PHP 8.4)
  • Sustituir getters y setters por property hooks y propiedades virtuales (PHP 8.4)
  • Usar la visibilidad asimétrica public private(set) (PHP 8.4) y la promoción final (PHP 8.5)
  • Declarar constantes de clase (tipadas desde PHP 8.3) y miembros estáticos
  • Comparar objetos, entender cómo se asignan y copiarlos con clone y __clone
  • Crear objetos inmutables con métodos with* y el nuevo clone with de PHP 8.5
  • Construir ejemplos completos: CuentaBancaria y Producto/Carrito

1. ¿Por qué Programación Orientada a Objetos?

Hasta ahora has organizado el código con datos (arrays) y funciones que trabajan con ellos. Funciona, pero en cuanto el proyecto crece aparecen problemas:

<?php
// Programación con arrays y funciones sueltas
$cuenta = ['titular' => 'Ana', 'saldo' => 100.0];

function retirar(array &$cuenta, float $cantidad): void
{
    if ($cantidad <= $cuenta['saldo']) {
        $cuenta['saldo'] -= $cantidad;
    }
}

retirar($cuenta, 30);
$cuenta['saldo'] = -5000;   // ❌ Nada impide saltarse las reglas
$cuenta['sladO'] = 10;      // ❌ Un error de escritura crea una clave nueva sin avisar
  • Nadie impide modificar el saldo directamente, saltándose las reglas de retirar().
  • Los datos y las funciones que los manejan están separados: hay que saber qué funciones existen para cada array.
  • La “forma” del array no está definida en ninguna parte: un error tipográfico crea una clave nueva.

📘 Concepto: La Programación Orientada a Objetos (POO) agrupa en una misma unidad (el objeto) los datos y el comportamiento que actúa sobre ellos, y permite proteger esos datos para que solo se modifiquen a través de operaciones controladas. A esto último se le llama encapsulación.

Ventaja Qué significa
Encapsulación El objeto protege sus datos y garantiza que siempre son válidos
Modelado El código refleja conceptos del mundo real: Cliente, Pedido, Factura
Reutilización Herencia, interfaces y composición (módulo 12)
Tipado function cobrar(Pedido $p) deja claro qué espera y lo comprueba PHP
Ecosistema Composer, Symfony, Laravel, Doctrine… todo el PHP moderno es POO

2. Clase y objeto

📘 Concepto: Una clase es un molde o plano que describe cómo serán ciertos objetos: qué datos tienen (propiedades) y qué pueden hacer (métodos). Un objeto (o instancia) es un ejemplar concreto creado a partir de ese molde, con sus propios valores.

Analogía: la clase es el plano de una casa diseñado por el arquitecto; los objetos son las casas construidas con ese plano. Todas tienen las mismas habitaciones, pero cada una tiene su propia dirección, su color de fachada y sus propios habitantes. Pintar una no cambia las demás.

classDiagram
    class Coche {
        +string marca
        +string color
        +int velocidad
        +acelerar(int kmh) void
        +frenar() void
    }
    Coche <.. cocheDeAna : instancia de
    Coche <.. cocheDeLuis : instancia de
    class cocheDeAna {
        marca = "Seat"
        color = "rojo"
        velocidad = 50
    }
    class cocheDeLuis {
        marca = "Toyota"
        color = "gris"
        velocidad = 0
    }

Tu primera clase

<?php
declare(strict_types=1);

class Coche
{
    // Propiedades: los datos de cada objeto
    public string $marca = '';
    public string $color = 'blanco';
    public int $velocidad = 0;

    // Métodos: lo que el objeto sabe hacer
    public function acelerar(int $kmh): void
    {
        $this->velocidad += $kmh;
    }

    public function frenar(): void
    {
        $this->velocidad = 0;
    }

    public function describir(): string
    {
        return "{$this->marca} {$this->color} a {$this->velocidad} km/h";
    }
}

// Crear objetos con new
$cocheDeAna = new Coche();
$cocheDeAna->marca = 'Seat';
$cocheDeAna->color = 'rojo';
$cocheDeAna->acelerar(50);

$cocheDeLuis = new Coche();
$cocheDeLuis->marca = 'Toyota';
$cocheDeLuis->color = 'gris';

echo $cocheDeAna->describir() . PHP_EOL;   // Seat rojo a 50 km/h
echo $cocheDeLuis->describir() . PHP_EOL;  // Toyota gris a 0 km/h
Sintaxis Significado
class Coche { } Declara la clase (nombre en PascalCase)
new Coche() Crea un objeto nuevo
$obj->propiedad Accede a una propiedad (sin $ delante del nombre)
$obj->metodo() Llama a un método
$this Dentro de un método, el objeto sobre el que se ha llamado
$obj?->metodo() Operador nullsafe: si $obj es null, devuelve null sin error

$this

📘 Concepto: $this es una variable especial disponible dentro de los métodos que apunta al objeto concreto que está ejecutando el método. Cuando llamas a $cocheDeAna->acelerar(50), dentro de acelerar() $this es $cocheDeAna.

⚠️ Importante: Un error muy común es escribir $this->$marca (con dos $). Lo correcto es $this->marca. Con $this->$marca PHP buscaría una propiedad cuyo nombre es el valor de la variable $marca.

Propiedades dinámicas (obsoletas)

<?php
$c = new Coche();
$c->matricula = '1234ABC';
// Deprecated: Creation of dynamic property Coche::$matricula is deprecated

Desde PHP 8.2, crear propiedades que no están declaradas en la clase está obsoleto. Es una gran ayuda: los errores tipográficos ya no pasan desapercibidos. Declara siempre todas las propiedades.


3. Visibilidad: public, protected y private

El ejemplo del coche tiene el mismo problema que el array: cualquiera puede escribir $coche->velocidad = -300. La visibilidad controla desde dónde se puede acceder a cada miembro.

Modificador Desde la propia clase Desde clases hijas (módulo 12) Desde fuera
public ✅ ✅ ✅
protected ✅ ✅ ❌
private ✅ ❌ ❌
<?php
declare(strict_types=1);

class Termostato
{
    private float $temperatura = 20.0;   // Nadie de fuera puede tocarla directamente

    public function subir(float $grados = 0.5): void
    {
        $this->fijar($this->temperatura + $grados);
    }

    public function bajar(float $grados = 0.5): void
    {
        $this->fijar($this->temperatura - $grados);
    }

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

    // Método privado: detalle interno, solo lo usa la propia clase
    private function fijar(float $nueva): void
    {
        $this->temperatura = max(10.0, min(30.0, $nueva)); // Siempre entre 10 y 30
    }
}

$t = new Termostato();
$t->subir(15);
echo $t->temperatura() . PHP_EOL;  // 30 (limitado)
// $t->temperatura = 99;           // Error: Cannot modify private property

💡 Consejo: Empieza con todo private y abre solo lo necesario. Los miembros públicos son la interfaz de tu clase (lo que otros usan y tú te comprometes a mantener); los privados son detalles internos que puedes cambiar cuando quieras.


4. Constructor

📘 Concepto: El constructor es un método especial llamado __construct() que PHP ejecuta automáticamente al crear el objeto con new. Sirve para recibir los datos iniciales y dejar el objeto en un estado válido desde el primer momento.

<?php
declare(strict_types=1);

class Usuario
{
    private string $nombre;
    private string $email;
    private array $roles;

    public function __construct(string $nombre, string $email, array $roles = ['usuario'])
    {
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            throw new InvalidArgumentException("Email no válido: $email");
        }
        $this->nombre = $nombre;
        $this->email = mb_strtolower($email);
        $this->roles = $roles;
    }

    public function presentarse(): string
    {
        return "Soy {$this->nombre} ({$this->email})";
    }
}

$ana = new Usuario('Ana', 'Ana@Correo.es');
echo $ana->presentarse() . PHP_EOL;   // Soy Ana (ana@correo.es)

// new Usuario('Luis', 'no-es-email'); // InvalidArgumentException

Con el constructor es imposible tener un Usuario sin email o con un email inválido. Esa garantía es uno de los grandes beneficios de la POO.

Constructor property promotion (PHP 8.0)

Declarar la propiedad, recibir el parámetro y asignarlo es muy repetitivo. La promoción de propiedades lo hace todo de una vez: si un parámetro del constructor lleva visibilidad, se convierte automáticamente en propiedad.

<?php
declare(strict_types=1);

// Antes: 3 veces cada nombre
class PuntoAntiguo
{
    private float $x;
    private float $y;

    public function __construct(float $x, float $y)
    {
        $this->x = $x;
        $this->y = $y;
    }
}

// Con promoción: una sola vez
class Punto
{
    public function __construct(
        private float $x = 0.0,
        private float $y = 0.0,
    ) {}

    public function distanciaA(Punto $otro): float
    {
        return sqrt(($this->x - $otro->x) ** 2 + ($this->y - $otro->y) ** 2);
    }
}

$a = new Punto(0, 0);
$b = new Punto(3, 4);
echo $a->distanciaA($b) . PHP_EOL; // 5

Puedes mezclar parámetros promocionados y normales, y añadir código en el cuerpo del constructor (se ejecuta después de asignar las propiedades promocionadas):

<?php
declare(strict_types=1);

class Producto
{
    public function __construct(
        public string $nombre,
        private float $precio,
        int $stockInicial = 0,      // Sin visibilidad: parámetro normal
    ) {
        if ($precio < 0) {
            throw new InvalidArgumentException('El precio no puede ser negativo');
        }
        $this->nombre = trim($nombre);
        $this->stock = $stockInicial;
    }

    private int $stock;
}

💡 Consejo: Fíjate en la coma final tras el último parámetro: está permitida desde PHP 8.0 y es la convención en constructores multilínea, porque al añadir un parámetro el diff de Git solo muestra una línea.

Propiedades tipadas

Desde PHP 7.4 las propiedades pueden (y deben) tener tipo. PHP impide asignarles valores de otro tipo:

<?php
declare(strict_types=1);

class Pedido
{
    public int $id;
    public ?string $notas = null;
    public array $lineas = [];
    public DateTimeImmutable $fecha;
    public int|float $total = 0;
}

$p = new Pedido();
// echo $p->id;    // Error: must not be accessed before initialization
$p->id = 15;
// $p->id = 'abc'; // TypeError

⚠️ Importante: Una propiedad tipada sin valor por defecto empieza en un estado no inicializado (que no es lo mismo que null). Leerla antes de asignarla produce un Error. Asígnale un valor por defecto o inicialízala en el constructor.


5. readonly: propiedades y clases

Propiedades readonly (PHP 8.1)

Una propiedad readonly solo se puede asignar una vez, desde dentro de la clase (normalmente en el constructor). Después es de solo lectura, también para la propia clase.

<?php
declare(strict_types=1);

class Factura
{
    public function __construct(
        public readonly string $numero,
        public readonly float $importe,
        public readonly DateTimeImmutable $fecha = new DateTimeImmutable(),
    ) {}
}

$f = new Factura('2026-0042', 250.0);
echo $f->numero . PHP_EOL;   // 2026-0042 (se puede leer públicamente)
// $f->importe = 0;          // Error: Cannot modify readonly property Factura::$importe

Esto permite tener propiedades públicas sin riesgo: se leen cómodamente sin getters, pero nadie puede cambiarlas.

📘 Concepto: Fíjate en new DateTimeImmutable() como valor por defecto de un parámetro: desde PHP 8.1 se permite new en valores por defecto.

Clases readonly (PHP 8.2)

Si todas las propiedades son readonly, marca la clase entera:

<?php
declare(strict_types=1);

readonly class Coordenadas
{
    public function __construct(
        public float $latitud,
        public float $longitud,
    ) {}
}

$sevilla = new Coordenadas(37.3891, -5.9845);
echo "{$sevilla->latitud}, {$sevilla->longitud}" . PHP_EOL;
Regla de readonly Detalle
Necesita tipo public readonly $x sin tipo no se permite
Sin valor por defecto en la declaración Se asigna en el constructor (o en un parámetro promocionado con defecto)
Solo se inicializa desde la propia clase Por defecto su escritura es protected(set) (desde PHP 8.4)
No admite property hooks Ver sección 7
Clase readonly Todas sus propiedades son readonly; no puede tener propiedades estáticas ni sin tipo

⚠️ Importante: readonly protege la propiedad, no el objeto que contiene. Si una propiedad readonly guarda un objeto mutable (como un DateTime o un objeto Carrito), no puedes sustituirlo por otro, pero sí llamar a sus métodos que lo modifican. Por eso, en objetos inmutables, usa también objetos inmutables dentro (DateTimeImmutable en lugar de DateTime).


6. new sin paréntesis para encadenar (PHP 8.4)

Antes de PHP 8.4, para llamar a un método justo después de crear un objeto había que envolver el new entre paréntesis. Ahora ya no es necesario:

<?php
declare(strict_types=1);

class Saludo
{
    public function __construct(private string $nombre) {}

    public function enMayusculas(): string
    {
        return mb_strtoupper("Hola, {$this->nombre}");
    }
}

// PHP 8.3 y anteriores
echo (new Saludo('Ana'))->enMayusculas() . PHP_EOL;

// PHP 8.4+
echo new Saludo('Ana')->enMayusculas() . PHP_EOL;            // HOLA, ANA
echo new DateTimeImmutable('2026-12-25')->format('l') . PHP_EOL; // Friday

⚠️ Importante: Los paréntesis del constructor sí son obligatorios en este caso, aunque no haya argumentos: new Clase()->metodo() funciona; new Clase->metodo() no.


7. Getters/setters frente a property hooks (PHP 8.4)

El enfoque clásico: getters y setters

Para validar o transformar un valor al asignarlo, tradicionalmente la propiedad se hace private y se accede mediante métodos:

<?php
declare(strict_types=1);

class ClienteClasico
{
    private string $email;

    public function __construct(string $email)
    {
        $this->setEmail($email);
    }

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

    public function setEmail(string $email): void
    {
        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
            throw new InvalidArgumentException('Email no válido');
        }
        $this->email = mb_strtolower($email);
    }
}

$c = new ClienteClasico('ANA@correo.es');
echo $c->getEmail() . PHP_EOL;   // ana@correo.es
$c->setEmail('luis@correo.es');

Funciona, pero genera mucho código repetitivo, y la mayoría de getters/setters no hacen nada más que leer y escribir.

Property hooks

📘 Concepto: Los property hooks (ganchos de propiedad) permiten asociar lógica a la lectura (get) y a la escritura (set) de una propiedad, directamente en su declaración. Desde fuera se usa como una propiedad normal ($c->email = '...'), pero se ejecuta tu código.

<?php
declare(strict_types=1);

class Cliente
{
    public string $email {
        set(string $valor) {
            if (filter_var($valor, FILTER_VALIDATE_EMAIL) === false) {
                throw new InvalidArgumentException("Email no válido: $valor");
            }
            $this->email = mb_strtolower($valor);   // Escribe en la propiedad real
        }
    }

    public string $nombre {
        set => trim($value);                      // Forma corta: el resultado se asigna
        get => mb_convert_case($this->nombre, MB_CASE_TITLE);
    }

    public function __construct(string $nombre, string $email)
    {
        $this->nombre = $nombre;   // Pasa por el hook set
        $this->email = $email;     // Pasa por el hook set
    }
}

$c = new Cliente('  ana garcía ', 'ANA@Correo.ES');
echo $c->nombre . PHP_EOL;  // Ana García
echo $c->email . PHP_EOL;   // ana@correo.es

$c->email = 'luis@correo.es';   // Se valida automáticamente
// $c->email = 'no-valido';     // InvalidArgumentException
Sintaxis Significado
set(string $valor) { ... } Hook de escritura con bloque. Dentro, asigna tú $this->prop = ...
set { ... } Igual, sin declarar el parámetro: se llama $value y tiene el tipo de la propiedad
set => expresión; Forma corta: el resultado de la expresión se guarda en la propiedad
get { return ...; } Hook de lectura con bloque
get => expresión; Forma corta de lectura
$this->prop dentro del hook Accede al valor real almacenado (no vuelve a llamar al hook)

Propiedades virtuales

Si un hook no usa nunca el valor almacenado de su propia propiedad, la propiedad es virtual: no ocupa memoria y se calcula cada vez. Es el equivalente a un getter calculado:

<?php
declare(strict_types=1);

class Rectangulo
{
    public function __construct(
        public float $ancho,
        public float $alto,
    ) {}

    // Virtual: solo tiene get, se calcula a partir de otras propiedades
    public float $area {
        get => $this->ancho * $this->alto;
    }

    public float $perimetro {
        get => 2 * ($this->ancho + $this->alto);
    }
}

$r = new Rectangulo(4, 2.5);
echo $r->area . PHP_EOL;       // 10
echo $r->perimetro . PHP_EOL;  // 13
$r->ancho = 10;
echo $r->area . PHP_EOL;       // 25 (se recalcula)
// $r->area = 5;               // Error: la propiedad virtual no tiene set

Hooks en propiedades promocionadas

<?php
declare(strict_types=1);

class Temperatura
{
    public function __construct(
        public float $celsius {
            set {
                if ($value < -273.15) {
                    throw new InvalidArgumentException('Por debajo del cero absoluto');
                }
                $this->celsius = $value;
            }
        },
    ) {}

    public float $fahrenheit {
        get => $this->celsius * 9 / 5 + 32;
        set {
            $this->celsius = ($value - 32) * 5 / 9;
        }
    }
}

$t = new Temperatura(25);
echo $t->fahrenheit . PHP_EOL;  // 77
$t->fahrenheit = 212;           // Escribe en celsius a través del hook
echo $t->celsius . PHP_EOL;     // 100

En $fahrenheit el hook set escribe en otra propiedad (celsius), así que $fahrenheit sigue siendo virtual aunque tenga set.

⚠️ Importante: Aquí el set debe escribirse con bloque. La forma corta set => expresión; siempre guarda el resultado en la propia propiedad ($this->fahrenheit), con lo que dejaría de ser virtual.

¿Cuándo usar cada enfoque?

Situación Recomendación
Propiedad sin lógica Propiedad pública normal (o readonly)
Validar o normalizar al asignar Hook set
Valor calculado a partir de otros Propiedad virtual con get
Solo lectura desde fuera, escritura interna Visibilidad asimétrica (sección 8)
Operación con varios parámetros o efectos Método normal: $cuenta->transferir($destino, 100)

💡 Consejo: Con los hooks puedes empezar con una propiedad pública simple y, si más adelante necesitas validarla, añadir un hook sin cambiar el código que la usa. Con el enfoque clásico tendrías que cambiar todos los $obj->prop por $obj->getProp().

⚠️ Importante: Los hooks no se pueden usar en propiedades readonly. Y no abuses de ellos: un get que hace consultas a la base de datos o cálculos costosos engaña a quien lo usa, porque parece un simple acceso a una propiedad.


8. Visibilidad asimétrica (PHP 8.4)

Muy a menudo quieres que una propiedad se pueda leer desde fuera pero solo se pueda modificar desde dentro de la clase. Antes había que hacerla private y escribir un getter. PHP 8.4 permite indicar una visibilidad distinta para la escritura:

<?php
declare(strict_types=1);

class Contador
{
    public private(set) int $valor = 0;  // Lectura pública, escritura privada

    public function incrementar(): void
    {
        $this->valor++;                   // ✅ Desde dentro sí
    }
}

$c = new Contador();
$c->incrementar();
$c->incrementar();
echo $c->valor . PHP_EOL;   // 2 (lectura pública, sin getter)
// $c->valor = 100;         // Error: Cannot modify private(set) property Contador::$valor from global scope
Declaración Lectura Escritura
public private(set) Desde cualquier sitio Solo la propia clase
public protected(set) Desde cualquier sitio La clase y sus hijas
protected private(set) La clase y sus hijas Solo la propia clase
private(set) (abreviado) Pública Solo la propia clase

También funciona en propiedades promocionadas:

<?php
declare(strict_types=1);

class Pedido
{
    public function __construct(
        public private(set) string $estado = 'pendiente',
        public private(set) array $historial = [],
    ) {}

    public function enviar(): void
    {
        if ($this->estado !== 'pendiente') {
            throw new LogicException("No se puede enviar un pedido {$this->estado}");
        }
        $this->estado = 'enviado';
        $this->historial[] = date('c') . ' enviado';
    }
}

$p = new Pedido();
$p->enviar();
echo $p->estado . PHP_EOL;   // enviado

📘 Concepto: Diferencia con readonly: una propiedad public private(set) se puede modificar muchas veces desde dentro de la clase; una readonly solo una vez. Usa readonly para valores que no cambian nunca (un ID) y la visibilidad asimétrica para estado interno que evoluciona (un saldo, un estado).

⚠️ Importante: La visibilidad asimétrica requiere que la propiedad tenga tipo. Y la visibilidad de escritura no puede ser más amplia que la de lectura (private public(set) no es válido).


9. Propiedades final en la promoción (PHP 8.5)

En PHP 8.4 las propiedades pueden declararse final, lo que impide que una clase hija las redeclare o les cambie los hooks (herencia, módulo 12). PHP 8.5 permite usar final también en las propiedades promocionadas del constructor:

<?php
declare(strict_types=1);

class Entidad
{
    public function __construct(
        final public readonly int $id,   // Ninguna clase hija podrá redeclarar $id
        public string $nombre,
    ) {}
}

class Cliente extends Entidad
{
    // public int $id;  // Error: Cannot override final property Entidad::$id
}

💡 Consejo: Usa final en propiedades cuyo comportamiento sea esencial para la clase padre (identificadores, invariantes) y que no deban alterarse por herencia. Si la clase entera no debe heredarse, márcala directamente como final class.


10. Constantes de clase

Una constante de clase es un valor fijo que pertenece a la clase, no a cada objeto. Se accede con NombreClase::CONSTANTE o, dentro de la clase, con self::CONSTANTE.

<?php
declare(strict_types=1);

class Iva
{
    // Constantes tipadas (PHP 8.3)
    public const float GENERAL = 21.0;
    public const float REDUCIDO = 10.0;
    public const float SUPERREDUCIDO = 4.0;
    public const string PAIS = 'ES';

    private const int DECIMALES = 2;

    public static function aplicar(float $base, float $tipo = self::GENERAL): float
    {
        return round($base * (1 + $tipo / 100), self::DECIMALES);
    }
}

echo Iva::GENERAL . PHP_EOL;                          // 21
echo Iva::aplicar(100) . PHP_EOL;                     // 121
echo Iva::aplicar(100, Iva::SUPERREDUCIDO) . PHP_EOL; // 104

// Acceso dinámico al nombre de la constante (PHP 8.3)
$tipo = 'REDUCIDO';
echo Iva::{$tipo} . PHP_EOL;                          // 10
Característica Versión
Visibilidad en constantes (private const) PHP 7.1
final const (las hijas no pueden redefinirla) PHP 8.1
Constantes tipadas (const string X) PHP 8.3
Acceso dinámico Clase::{$nombre} PHP 8.3

💡 Consejo: Las constantes eliminan los “números mágicos”: Iva::REDUCIDO dice mucho más que un 10 suelto. Cuando tengas un conjunto cerrado de valores relacionados (estados de un pedido, tipos de IVA), considera los enums del módulo 12.


11. Propiedades y métodos estáticos

📘 Concepto: Un miembro estático pertenece a la clase, no a cada objeto. Se comparte entre todas las instancias y se usa sin crear ningún objeto: Clase::metodo(), Clase::$propiedad. Dentro de la clase se accede con self::.

<?php
declare(strict_types=1);

class Conexion
{
    private static int $abiertas = 0;      // Compartida por todas las instancias

    public function __construct(public readonly string $servidor)
    {
        self::$abiertas++;
    }

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

new Conexion('db1');
new Conexion('db2');
echo Conexion::abiertas() . PHP_EOL; // 2

Métodos de fábrica (named constructors)

Un uso muy común de los métodos estáticos es ofrecer constructores con nombre, más expresivos que new:

<?php
declare(strict_types=1);

class Dinero
{
    private function __construct(            // Constructor privado: obliga a usar las fábricas
        public readonly int $centimos,
        public readonly string $moneda,
    ) {}

    public static function euros(float $cantidad): self
    {
        return new self((int) round($cantidad * 100), 'EUR');
    }

    public static function desdeCentimos(int $centimos, string $moneda = 'EUR'): self
    {
        return new self($centimos, $moneda);
    }

    public static function cero(): self
    {
        return new self(0, 'EUR');
    }

    public function formatear(): string
    {
        return number_format($this->centimos / 100, 2, ',', '.') . ' €';
    }
}

echo Dinero::euros(19.99)->formatear() . PHP_EOL;          // 19,99 €
echo Dinero::desdeCentimos(150)->formatear() . PHP_EOL;    // 1,50 €

💡 Consejo: Guardar dinero en céntimos enteros evita los errores de redondeo de los float (0.1 + 0.2 !== 0.3). Es una práctica profesional muy extendida.

⚠️ Importante: No abuses de lo estático. Un método estático no puede usar $this, y las propiedades estáticas son, en la práctica, variables globales con otro nombre: dificultan las pruebas y crean dependencias ocultas. Úsalos para fábricas, utilidades puras y contadores, no como forma habitual de programar.


12. Comparar objetos: == frente a ===

<?php
declare(strict_types=1);

class Color
{
    public function __construct(public int $r, public int $g, public int $b) {}
}

$a = new Color(255, 0, 0);
$b = new Color(255, 0, 0);
$c = $a;

var_dump($a == $b);   // true:  misma clase y mismas propiedades con los mismos valores
var_dump($a === $b);  // false: son dos objetos distintos
var_dump($a === $c);  // true:  $a y $c son el MISMO objeto

var_dump($a instanceof Color); // true
echo $a::class . PHP_EOL;      // Color
Operador Con objetos significa
== Misma clase y propiedades iguales (comparación con == de cada una)
=== El mismo objeto (la misma instancia en memoria)
instanceof El objeto es de esa clase (o de una hija, o implementa esa interfaz)

💡 Consejo: Para comparar objetos de valor (dinero, fechas, coordenadas) es mejor escribir un método explícito equals(self $otro): bool que dependa de ==, porque así tú decides qué significa “ser iguales” (por ejemplo, ignorar mayúsculas en un email).


13. Objetos y referencias: asignación frente a clone

📘 Concepto: Una variable no “contiene” el objeto, sino un identificador que apunta a él. Al asignar $b = $a o pasar un objeto a una función, se copia ese identificador: ambas variables apuntan al mismo objeto. Es como tener dos mandos a distancia de la misma tele.

<?php
declare(strict_types=1);

class Caja
{
    public function __construct(public int $contenido = 0) {}
}

$a = new Caja(1);
$b = $a;              // Copia el "mando", no la tele
$b->contenido = 99;

echo $a->contenido . PHP_EOL; // 99 (¡también ha cambiado $a!)

function vaciar(Caja $caja): void
{
    $caja->contenido = 0;      // Modifica el objeto original
}

vaciar($a);
echo $a->contenido . PHP_EOL; // 0

Esto es muy diferente de los arrays, que se copian al asignarlos:

Tipo $b = $a Pasar a una función
Array, string, int… Copia independiente Copia (salvo &)
Objeto Mismo objeto Mismo objeto (la función puede modificarlo)

clone: una copia de verdad

<?php
$a = new Caja(1);
$b = clone $a;        // Objeto NUEVO con los mismos valores
$b->contenido = 99;

echo $a->contenido . PHP_EOL; // 1 (no ha cambiado)
var_dump($a == $b, $a === $b); // false, false

Copia superficial y __clone()

clone hace una copia superficial: si una propiedad contiene otro objeto, la copia y el original comparten ese objeto interno. Para copiarlo también (copia profunda), implementa __clone(), que PHP ejecuta sobre la copia recién creada:

<?php
declare(strict_types=1);

class Direccion
{
    public function __construct(public string $ciudad) {}
}

class Persona
{
    public function __construct(
        public string $nombre,
        public Direccion $direccion,
    ) {}

    public function __clone(): void
    {
        // $this es la COPIA: le damos su propia Direccion
        $this->direccion = clone $this->direccion;
    }
}

$ana = new Persona('Ana', new Direccion('Sevilla'));
$gemela = clone $ana;
$gemela->nombre = 'Eva';
$gemela->direccion->ciudad = 'Cádiz';

echo $ana->direccion->ciudad . PHP_EOL;    // Sevilla (gracias a __clone)
echo $gemela->direccion->ciudad . PHP_EOL; // Cádiz

💡 Consejo: Desde PHP 8.3, dentro de __clone() también puedes reasignar propiedades readonly de la copia, lo que permite hacer copias profundas de objetos inmutables.


14. Objetos inmutables y clone with (PHP 8.5)

📘 Concepto: Un objeto inmutable no cambia nunca después de crearse. Para “modificarlo” se crea un objeto nuevo con el cambio aplicado. Es el modelo de DateTimeImmutable: $fecha->modify('+1 day') no altera $fecha, devuelve otra fecha.

Los objetos inmutables evitan toda una familia de errores: si pasas un objeto a una función, tienes la garantía de que no te lo va a cambiar “por detrás”. Por convención, los métodos que devuelven una copia modificada se llaman with*().

El problema antes de PHP 8.5

Con clases readonly, implementar with*() era tedioso: había que llamar al constructor pasando todas las propiedades:

<?php
declare(strict_types=1);

readonly class ColorAntiguo
{
    public function __construct(
        public int $rojo,
        public int $verde,
        public int $azul,
        public int $alfa = 255,
    ) {}

    public function withAlfa(int $alfa): static
    {
        // Hay que repetir todas las propiedades; si añades una, tienes que tocar todos los with*
        return new static($this->rojo, $this->verde, $this->azul, $alfa);
    }
}

clone() con propiedades a modificar

PHP 8.5 permite pasar a clone un array con las propiedades a cambiar en la copia:

<?php
declare(strict_types=1);

readonly class Color
{
    public function __construct(
        public int $rojo,
        public int $verde,
        public int $azul,
        public int $alfa = 255,
    ) {}

    #[\NoDiscard]
    public function withAlfa(int $alfa): static
    {
        return clone($this, ['alfa' => $alfa]);
    }

    #[\NoDiscard]
    public function withRojo(int $rojo): static
    {
        return clone($this, ['rojo' => $rojo]);
    }

    public function hex(): string
    {
        return sprintf('#%02x%02x%02x%02x', $this->rojo, $this->verde, $this->azul, $this->alfa);
    }
}

$azul = new Color(79, 91, 147);
$azulTransparente = $azul->withAlfa(128);

echo $azul->hex() . PHP_EOL;              // #4f5b93ff (el original no cambia)
echo $azulTransparente->hex() . PHP_EOL;  // #4f5b9380

$azul->withRojo(0); // Warning (#[\NoDiscard]): ¡el resultado se pierde y $azul no cambia!

💡 Consejo: El atributo #[\NoDiscard] del módulo 07 es el compañero perfecto de los métodos with*: el error más común con objetos inmutables es llamar a $color->withAlfa(128); esperando que $color cambie.

Reglas de clone with

Regla Detalle
Sintaxis clone($objeto, ['propiedad' => valor, ...]); clone $obj y clone($obj) siguen funcionando
Visibilidad Se respeta: solo puedes modificar las propiedades que podrías escribir desde ese ámbito
readonly Se pueden modificar en la copia desde dentro de la clase. Desde fuera, solo si la propiedad es public(set)
Orden Primero se ejecuta __clone() y después se asignan las nuevas propiedades, en el orden del array
Property hooks Los hooks set se ejecutan con normalidad: la validación se mantiene
Como callable clone(...) es un callable: array_map(clone(...), $objetos)
<?php
// Desde FUERA de la clase, una propiedad readonly pública es protected(set):
$verde = clone($azul, ['verde' => 255]);
// Error: Cannot modify protected(set) readonly property Color::$verde
// Por eso los cambios se hacen dentro de métodos with*()

Ejemplo: una petición HTTP inmutable

<?php
declare(strict_types=1);

final readonly class Peticion
{
    /** @param array<string, string> $cabeceras */
    public function __construct(
        public string $metodo = 'GET',
        public string $url = '/',
        public array $cabeceras = [],
    ) {}

    #[\NoDiscard]
    public function withMetodo(string $metodo): static
    {
        return clone($this, ['metodo' => strtoupper($metodo)]);
    }

    #[\NoDiscard]
    public function withCabecera(string $nombre, string $valor): static
    {
        return clone($this, ['cabeceras' => [...$this->cabeceras, $nombre => $valor]]);
    }
}

$base = new Peticion(url: 'https://api.example/productos');
$post = $base
    ->withMetodo('post')
    ->withCabecera('Content-Type', 'application/json')
    ->withCabecera('Accept', 'application/json');

echo $base->metodo . ' ' . count($base->cabeceras) . PHP_EOL;  // GET 0
echo $post->metodo . ' ' . count($post->cabeceras) . PHP_EOL;  // POST 2

Este patrón es exactamente el que usa el estándar PSR-7 de mensajes HTTP, que verás en el módulo 13.


15. Destructor

El destructor __destruct() se ejecuta automáticamente cuando el objeto deja de existir: cuando ya no queda ninguna variable que apunte a él, o al terminar el script.

<?php
declare(strict_types=1);

class Temporizador
{
    private float $inicio;

    public function __construct(private string $tarea)
    {
        $this->inicio = microtime(true);
        echo "Inicio: {$this->tarea}" . PHP_EOL;
    }

    public function __destruct()
    {
        $ms = (microtime(true) - $this->inicio) * 1000;
        printf("Fin: %s (%.2f ms)\n", $this->tarea, $ms);
    }
}

function procesar(): void
{
    $t = new Temporizador('procesar datos');
    usleep(50_000);
}   // Al salir de la función, $t desaparece y se llama al destructor

procesar();
// Inicio: procesar datos
// Fin: procesar datos (50.12 ms)

$t2 = new Temporizador('otra tarea');
$t2 = null;   // Se destruye inmediatamente

💡 Consejo: En PHP rara vez necesitarás destructores: los ficheros, conexiones y memoria se liberan solos al terminar la petición. Son útiles para liberar recursos externos (bloqueos, ficheros temporales) en scripts de larga duración.


16. Ejemplo completo: CuentaBancaria

Este ejemplo reúne encapsulación, validación, visibilidad asimétrica, readonly, constantes tipadas y métodos de fábrica:

<?php
declare(strict_types=1);

final class CuentaBancaria
{
    public const float COMISION_TRANSFERENCIA = 0.5;
    public const float DESCUBIERTO_MAXIMO = 0.0;

    private static int $ultimoNumero = 0;

    public private(set) float $saldo = 0.0;

    /** @var list<string> */
    public private(set) array $movimientos = [];

    public string $titular {
        set {
            $limpio = trim($value);
            if (mb_strlen($limpio) < 3) {
                throw new InvalidArgumentException('El titular debe tener al menos 3 caracteres');
            }
            $this->titular = $limpio;
        }
    }

    // Propiedad virtual
    public string $resumen {
        get => sprintf('%s | %s | %s €', $this->iban, $this->titular, number_format($this->saldo, 2, ',', '.'));
    }

    private function __construct(
        public readonly string $iban,
        string $titular,
    ) {
        $this->titular = $titular;
    }

    public static function abrir(string $titular, float $depositoInicial = 0.0): self
    {
        $numero = str_pad((string) ++self::$ultimoNumero, 10, '0', STR_PAD_LEFT);
        $cuenta = new self("ES00 2100 0000 00 $numero", $titular);
        if ($depositoInicial > 0) {
            $cuenta->ingresar($depositoInicial, 'Depósito inicial');
        }
        return $cuenta;
    }

    public function ingresar(float $cantidad, string $concepto = 'Ingreso'): void
    {
        $this->validarCantidad($cantidad);
        $this->saldo += $cantidad;
        $this->registrar("+$cantidad", $concepto);
    }

    public function retirar(float $cantidad, string $concepto = 'Retirada'): void
    {
        $this->validarCantidad($cantidad);
        if ($this->saldo - $cantidad < -self::DESCUBIERTO_MAXIMO) {
            throw new RuntimeException("Saldo insuficiente para retirar $cantidad €");
        }
        $this->saldo -= $cantidad;
        $this->registrar("-$cantidad", $concepto);
    }

    public function transferir(self $destino, float $cantidad): void
    {
        if ($destino === $this) {
            throw new LogicException('No puedes transferir a la misma cuenta');
        }
        $this->retirar($cantidad + self::COMISION_TRANSFERENCIA, "Transferencia a {$destino->titular}");
        $destino->ingresar($cantidad, "Transferencia de {$this->titular}");
    }

    private function validarCantidad(float $cantidad): void
    {
        if ($cantidad <= 0) {
            throw new InvalidArgumentException('La cantidad debe ser positiva');
        }
    }

    private function registrar(string $importe, string $concepto): void
    {
        $this->movimientos[] = sprintf('%s %8s € %s', date('Y-m-d H:i'), $importe, $concepto);
    }
}

$ana  = CuentaBancaria::abrir('Ana García', 500);
$luis = CuentaBancaria::abrir('Luis Pérez');

$ana->transferir($luis, 120);
$luis->retirar(20, 'Cajero');

echo $ana->resumen . PHP_EOL;    // ES00 2100 0000 00 0000000001 | Ana García | 379,50 €
echo $luis->resumen . PHP_EOL;   // ES00 2100 0000 00 0000000002 | Luis Pérez | 100,00 €

foreach ($ana->movimientos as $m) {
    echo $m . PHP_EOL;
}

try {
    $luis->retirar(1000);
} catch (RuntimeException $e) {
    echo 'Error: ' . $e->getMessage() . PHP_EOL;   // Error: Saldo insuficiente para retirar 1000 €
}

// $ana->saldo = 1_000_000;   // Error: Cannot modify private(set) property

17. Ejemplo completo: Producto y Carrito

Dos clases que colaboran: un Producto inmutable y un Carrito que gestiona líneas.

<?php
declare(strict_types=1);

final readonly class Producto
{
    public function __construct(
        public string $sku,
        public string $nombre,
        public float $precio,
        public float $iva = 21.0,
    ) {
        if ($precio < 0) {
            throw new InvalidArgumentException('Precio negativo');
        }
    }

    public function precioConIva(): float
    {
        return round($this->precio * (1 + $this->iva / 100), 2);
    }

    #[\NoDiscard]
    public function withPrecio(float $precio): static
    {
        return clone($this, ['precio' => $precio]);
    }
}

final class Carrito
{
    /** @var array<string, array{producto: Producto, cantidad: int}> */
    private array $lineas = [];

    public int $unidades {
        get => array_sum(array_column($this->lineas, 'cantidad'));
    }

    public float $total {
        get => round(array_sum(array_map(
            fn(array $l) => $l['producto']->precioConIva() * $l['cantidad'],
            $this->lineas,
        )), 2);
    }

    public function anadir(Producto $producto, int $cantidad = 1): static
    {
        if ($cantidad < 1) {
            throw new InvalidArgumentException('Cantidad no válida');
        }
        $actual = $this->lineas[$producto->sku]['cantidad'] ?? 0;
        $this->lineas[$producto->sku] = ['producto' => $producto, 'cantidad' => $actual + $cantidad];
        return $this;   // Devolver $this permite encadenar llamadas
    }

    public function quitar(string $sku): static
    {
        unset($this->lineas[$sku]);
        return $this;
    }

    public function estaVacio(): bool
    {
        return $this->lineas === [];
    }

    public function ticket(): string
    {
        $texto = '';
        foreach ($this->lineas as ['producto' => $p, 'cantidad' => $c]) {
            $texto .= sprintf("%-22s %2d x %7.2f = %8.2f €\n", $p->nombre, $c, $p->precioConIva(), $p->precioConIva() * $c);
        }
        return $texto . sprintf("%'-50s\n%-37s %8.2f €\n", '', "TOTAL ({$this->unidades} uds.)", $this->total);
    }
}

$cafe  = new Producto('CAF-01', 'Café en grano 1 kg', 14.90, 10);
$taza  = new Producto('TAZ-01', 'Taza de cerámica', 6.60);
$filtro = new Producto('FIL-01', 'Filtros (100 uds.)', 3.20);

$carrito = new Carrito()          // PHP 8.4: new sin paréntesis extra
    ->anadir($cafe, 2)
    ->anadir($taza)
    ->anadir($filtro, 3)
    ->anadir($taza);

echo $carrito->ticket();

// Rebajas: el producto original no cambia
$cafeRebajado = $cafe->withPrecio(11.90);
echo $cafe->precio . ' → ' . $cafeRebajado->precio . PHP_EOL; // 14.9 → 11.9
Café en grano 1 kg      2 x   16.39 =    32.78 €
Taza de cerámica        2 x    7.99 =    15.98 €
Filtros (100 uds.)      3 x    3.87 =    11.61 €
--------------------------------------------------
TOTAL (7 uds.)                           60.37 €

💡 Consejo: Observa el reparto de responsabilidades: el Producto sabe calcular su precio con IVA; el Carrito sabe sumar líneas, pero no sabe cómo se calcula el IVA. Cada clase tiene una única responsabilidad clara. Es el primer principio SOLID, que verás en el módulo 12.


18. Errores comunes

Error Causa Solución
Using $this when not in object context $this en un método estático o fuera de una clase Usa self:: en estáticos, o haz el método no estático
must not be accessed before initialization Propiedad tipada sin valor Valor por defecto o asignarla en el constructor
Creation of dynamic property is deprecated Propiedad no declarada Declárala en la clase
Cannot modify readonly property Segunda asignación a readonly Crea un objeto nuevo (clone con propiedades / with*)
Cannot modify private(set) property Escritura desde fuera Añade un método que haga el cambio con sus reglas
Un objeto cambia “solo” Dos variables apuntan al mismo objeto Usa clone o diseña objetos inmutables
$this->$nombre Dos $ $this->nombre
new Clase->metodo() no funciona Faltan los paréntesis del constructor new Clase()->metodo()

19. Ejercicios

Ejercicio 1: Clase Libro

Crea una clase Libro con propiedades promocionadas readonly para isbn, titulo, autor y anio, y una propiedad public private(set) bool $prestado. Añade los métodos prestar() y devolver() (que lancen LogicException si el estado no es el adecuado) y una propiedad virtual antiguedad que calcule los años desde su publicación. Crea tres libros y prueba todos los casos.

Ejercicio 2: Validación con property hooks

Crea una clase Alumno con hooks set para: nombre (recorta espacios, mínimo 2 caracteres), email (valida y pasa a minúsculas), nota (float entre 0 y 10; si no, InvalidArgumentException). Añade una propiedad virtual calificacion que devuelva Suspenso, Aprobado, Notable o Sobresaliente usando match(true). Crea una lista de alumnos y muestra una tabla ordenada por nota.

Ejercicio 3: Conversor de unidades con propiedades virtuales

Crea una clase Distancia que almacene internamente solo los metros y exponga las propiedades virtuales kilometros, millas y pies con hooks get y set (al asignar $d->millas = 1 se actualizan los metros). Añade un método estático de fábrica Distancia::desdeKilometros(float $km).

Ejercicio 4: Contador de instancias y fábricas

Crea una clase Ticket con un número correlativo generado mediante una propiedad estática privada (T-0001, T-0002…), una prioridad (baja, media, alta como constantes de clase tipadas) y fábricas estáticas Ticket::urgente(string $asunto) y Ticket::normal(string $asunto). Usa un constructor privado.

Ejercicio 5: Copia profunda

Crea las clases Equipo (nombre y un array de objetos Jugador) y Jugador (nombre y dorsal). Demuestra con var_dump y === que un clone sin __clone() comparte los jugadores entre el original y la copia. Después implementa __clone() para que la copia tenga sus propios jugadores y comprueba que modificar uno no afecta al otro.

Ejercicio 6: Objeto inmutable con clone with

Crea una clase readonly llamada Fecha (día, mes y año, validados en el constructor con checkdate()) con métodos withDia(), withMes(), withAnio() y sumarDias(int $n) marcados con #[\NoDiscard] e implementados con clone($this, [...]). Comprueba que el objeto original no cambia nunca y qué ocurre si intentas clone($fecha, ['dia' => 5]) desde fuera de la clase.

Ejercicio 7: Tienda con carrito

Amplía el ejemplo Producto/Carrito con: un método cambiarCantidad(string $sku, int $cantidad), un descuento por cupón (public private(set) ?string $cupon y un método aplicarCupon() que acepte solo códigos de una constante de clase), gastos de envío como propiedad virtual (gratis a partir de 50 €) y un método estaVacio(). Muestra el ticket antes y después de aplicar el cupón.


Resumen

Concepto Sintaxis
Clase y objeto class Coche { } / $c = new Coche();
Propiedad y método $c->color, $c->acelerar(50)
Visibilidad public, protected, private
Constructor public function __construct(...) { }
Promoción __construct(private string $nombre) {}
Propiedad tipada public ?string $notas = null;
readonly public readonly int $id; / readonly class X
new encadenado (8.4) new Clase()->metodo()
Hook set / get (8.4) public string $x { set => trim($value); get => ...; }
Propiedad virtual (8.4) public float $area { get => $this->a * $this->b; }
Visibilidad asimétrica (8.4) public private(set) float $saldo;
Promoción final (8.5) __construct(final public readonly int $id)
Constante tipada (8.3) public const float IVA = 21.0;
Estático self::$contador, Clase::metodo()
Comparar == (mismos valores), === (mismo objeto)
Copiar clone $obj + __clone() para copia profunda
Clone with (8.5) clone($this, ['prop' => $valor])
Destructor public function __destruct() { }