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,protectedyprivatepara encapsular - Escribir constructores, usar constructor property promotion y propiedades tipadas
- Crear propiedades y clases
readonlyy encadenarnewsin 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ónfinal(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
cloney__clone - Crear objetos inmutables con métodos
with*y el nuevo clone with de PHP 8.5 - Construir ejemplos completos:
CuentaBancariayProducto/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:
$thises 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 deacelerar()$thises$cocheDeAna.
⚠️ Importante: Un error muy común es escribir
$this->$marca(con dos$). Lo correcto es$this->marca. Con$this->$marcaPHP 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
privatey 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 connew. 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 unError. 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 permitenewen 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:
readonlyprotege la propiedad, no el objeto que contiene. Si una propiedadreadonlyguarda un objeto mutable (como unDateTimeo un objetoCarrito), 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 (DateTimeImmutableen lugar deDateTime).
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
setdebe escribirse con bloque. La forma cortaset => 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->proppor$obj->getProp().
⚠️ Importante: Los hooks no se pueden usar en propiedades
readonly. Y no abuses de ellos: ungetque 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 propiedadpublic private(set)se puede modificar muchas veces desde dentro de la clase; unareadonlysolo una vez. Usareadonlypara 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
finalen 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 comofinal 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::REDUCIDOdice mucho más que un10suelto. 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 conself::.
<?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): boolque 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 = $ao 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 propiedadesreadonlyde 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étodoswith*: el error más común con objetos inmutables es llamar a$color->withAlfa(128);esperando que$colorcambie.
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
Productosabe calcular su precio con IVA; elCarritosabe 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() { } |