← Volver a los artículos
Arquitectura PHP y patrones de diseño

Value Objects en PHP: deja de escribir código obsesionado con las primitivas

Descubre cómo los Value Objects eliminan la obsesión por las primitivas en PHP, con ejemplos reales de clases Email y Money que aportan seguridad de tipos, validación y claridad a tu código de dominio.

Value Objects en PHP: deja de escribir código obsesionado con las primitivas
Artículo destacado ↗

Value Objects en PHP: deja de escribir código obsesionado con las primitivas

Mira casi cualquier base de código PHP y encontrarás el mismo patrón: correos electrónicos almacenados como cadenas, dinero almacenado como floats, IDs almacenados como enteros, números de teléfono almacenados como cadenas. Esto se llama primitive obsession (obsesión por las primitivas) — el uso excesivo de tipos primitivos para representar conceptos del dominio. Es uno de los anti-patrones más comunes y más dañinos en el desarrollo PHP moderno.

Los Value Objects son la cura. Aportan seguridad de tipos, validación y claridad a tu código — y una vez que empieces a usarlos, te preguntarás cómo pudiste vivir sin ellos.

¿Qué es un Value Object?

Un Value Object (VO, objeto-valor) es un objeto pequeño e inmutable que representa un concepto de tu dominio. A diferencia de una Entity, un Value Object no tiene identidad — se define enteramente por sus valores. Dos Value Objects con los mismos valores se consideran iguales.

Los ejemplos clásicos incluyen:

  • Email — en lugar de una cadena cruda
  • Money — en lugar de un float más una cadena de moneda
  • UserId — en lugar de un entero
  • PhoneNumber — en lugar de una cadena
  • DateRange — en lugar de dos objetos DateTime
  • Address — en lugar de un array de cadenas
  • Password — en lugar de una cadena simple

El problema con las primitivas

Considera este código típico:

<?php function registerUser(string $email, float $balance): void { // ¿Es $email válido? Quién sabe. // ¿Está $balance en USD, EUR o BTC? Ni idea. // ¿Puede $balance ser negativo? Quizás. }

Cada valor primitivo lleva suposiciones ocultas. El sistema de tipos no te dice nada sobre:

  • Si el valor es válido
  • En qué formato está
  • Qué operaciones están permitidas
  • Qué unidades o moneda representa
  • Si puede modificarse o reutilizarse de forma segura

¿El resultado? Lógica de validación dispersa por todas partes, errores por formatos incompatibles y código difícil de razonar.

Tu primer Value Object: Email

Reemplacemos una cadena primitiva con un Value Object adecuado:

<?php declare(strict_types=1); final class Email { private function __construct( private readonly string $value, ) { } public static function fromString(string $value): self { $normalized = mb_strtolower(trim($value)); if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException( sprintf('"%s" is not a valid email address.', $value) ); } return new self($normalized); } public function toString(): string { return $this->value; } public function equals(self $other): bool { return $this->value === $other->value; } public function __toString(): string { return $this->value; } }

Ahora el sistema de tipos te protege. Si tienes un objeto Email, sabes que es válido. No más comprobaciones de validación dispersas.

Usar el Value Object

<?php final class User { public function __construct( public readonly UserId $id, public readonly Email $email, public readonly string $name, ) { } } // Ahora el constructor impone la validez: $user = new User( id: UserId::fromInt(42), email: Email::fromString('John@Example.com'), name: 'John Doe', ); echo $user->email->toString(); // "john@example.com" (normalizado)

Un ejemplo más complejo: Money

El dinero es el caso clásico donde las primitivas fallan catastróficamente. Los floats pierden precisión, y mezclar monedas es un error silencioso que solo espera a ocurrir.

<?php declare(strict_types=1); final class Money { private function __construct( private readonly int $amountInCents, private readonly string $currency, ) { if ($amountInCents < 0) { throw new InvalidArgumentException('Amount cannot be negative.'); } if (!preg_match('/^[A-Z]{3}$/', $currency)) { throw new InvalidArgumentException('Currency must be a 3-letter ISO code.'); } } public static function of(int $amountInCents, string $currency): self { return new self($amountInCents, $currency); } public static function fromFloat(float $amount, string $currency): self { return new self((int) round($amount * 100), $currency); } public function add(self $other): self { $this->assertSameCurrency($other); return new self( $this->amountInCents + $other->amountInCents, $this->currency, ); } public function subtract(self $other): self { $this->assertSameCurrency($other); return new self( $this->amountInCents - $other->amountInCents, $this->currency, ); } public function isGreaterThan(self $other): bool { $this->assertSameCurrency($other); return $this->amountInCents > $other->amountInCents; } public function amount(): int { return $this->amountInCents; } public function currency(): string { return $this->currency; } public function format(): string { return number_format($this->amountInCents / 100, 2) . ' ' . $this->currency; } public function equals(self $other): bool { return $this->amountInCents === $other->amountInCents && $this->currency === $other->currency; } private function assertSameCurrency(self $other): void { if ($this->currency !== $other->currency) { throw new InvalidArgumentException( sprintf('Cannot operate on %s and %s.', $this->currency, $other->currency) ); } } }

Ahora observa lo limpio que se vuelve el código llamante:

<?php $price = Money::fromFloat(19.99, 'USD'); $tax = Money::fromFloat(1.60, 'USD'); $total = $price->add($tax); echo $total->format(); // "21.59 USD" // Esto lanza una excepción en lugar de corromper silenciosamente los datos: $price->add(Money::of(500, 'EUR'));

Características clave de un buen Value Object

  • Inmutable — una vez creado, nunca cambia. Las operaciones devuelven nuevas instancias.
  • Auto-validante — una instancia no puede existir en un estado inválido.
  • Sin identidad — la igualdad se basa en los valores, no en un ID de base de datos.
  • Sin efectos secundarios — sin llamadas a la base de datos, sin logging, sin estado global.
  • Pequeño — representa un concepto, no un agregado entero.
  • Reemplazable — el objeto completo puede intercambiarse, no mutarse campo por campo.

Value Object vs. DTO vs. Entity

Es importante distinguir estos tres patrones:

  • Value Object — inmutable, definido por valores, contiene comportamiento relacionado con esos valores (como add() en Money).
  • DTO — inmutable o mutable, transporta datos entre capas, no contiene lógica de negocio.
  • Entity — tiene una identidad única que persiste en el tiempo, incluso cuando sus atributos cambian.

Donde brillan los Value Objects

  • Modelos de dominio — representación de correos, dinero, IDs, fechas, direcciones.
  • Validación de entrada — crear un VO desde la entrada del usuario garantiza la validez en todo el flujo posterior.
  • Reglas de negocio — encapsular lógica como la coincidencia de monedas o la verificación de rangos de fechas.
  • Testing — pruebas más simples, rápidas y enfocadas sin dependencias de la base de datos.
  • Refactorización — cuando cambian los requisitos, cambias el VO en un solo lugar.

Errores comunes a evitar

  • Añadir setters — esto rompe la inmutabilidad. Si necesitas un valor diferente, crea una nueva instancia.
  • Hacerlos demasiado grandes — un VO debe representar un concepto, no una raíz de agregado entera.
  • Añadir lógica de persistencia — mantén las preocupaciones de la base de datos en los repositorios, no en los VO.
  • Ignorar la igualdad — implementa siempre un método equals().
  • Lanzar excepciones genéricas — define excepciones específicas del dominio para un manejo de errores más claro.

Un camino de refactorización práctico

No necesitas reescribir toda tu aplicación de la noche a la mañana. Empieza aquí:

  1. Identifica un campo primitivo que cause errores o confusión (correos, dinero, IDs).
  2. Crea un Value Object para él con validación y métodos útiles.
  3. Reemplaza la primitiva en una clase o un módulo.
  4. Deja que el compilador y tus tests guíen el resto.
  5. Repite con el siguiente candidato obvio.

Conclusión

Los Value Objects son una de las refactorizaciones de mayor impacto que puedes hacer en una base de código PHP. Eliminan la obsesión por las primitivas, centralizan la validación, hacen imposible representar estados inválidos y convierten tu código en una expresión auto-documentada de tu dominio. El sistema de tipos se convierte en un aliado en lugar de una formalidad.

Empieza con un solo Value Object — quizás Email o Money. Una vez que veas cuánta claridad aporta a tu código, nunca volverás a pasar cadenas crudas por ahí.

Deja de escribir código obsesionado con las primitivas. Deja que tus tipos hablen por tu dominio.

Tecnologías y temas

Etiquetas del artículo

No hay artículos que coincidan con estos filtros.

¿Tienes un proyecto o una idea para discutir?

Hablemos ↗