<?php
/**
* State Pattern vs. Enums en PHP moderno
*
* Los Enums nativos de PHP 8.1 son perfectos para representar estados
* simples. El problema llega cuando el negocio crece y cada estado
* empieza a tener comportamiento propio: validaciones distintas,
* llamadas a APIs externas, efectos secundarios... Ahí el Enum revienta.
*
* Este archivo muestra:
* 1. Enum puro cuándo vale y cuándo se rompe
* 2. State Pattern cómo aislar el comportamiento por estado
* 3. Combinación: Enum para persistencia + State Objects para lógica
* 4. Factory para resolver dependencias
* 5. Tests unitarios con el patrón combinado
*/
declare(strict_types=1);
// ============================================================
// PARTE 1: ENUM PURO BIEN CUANDO EL ESTADO ES UN SIMPLE LABEL
// ============================================================
// Para estados sin lógica propia (Draft/Published, activo/inactivo...),
// un backed enum es la solución correcta. Simple, type-safe, persistible.
enum PostStatus: string
{
case Draft = 'draft';
case Published = 'published';
case Archived = 'archived';
public function label(): string
{
return match ($this) {
self::Draft => 'Borrador',
self::Published => 'Publicado',
self::Archived => 'Archivado',
};
}
public function isVisible(): bool
{
return $this === self::Published;
}
}
// Uso limpio: filtrado, serialización, display.
$status = PostStatus::from('published');
echo $status->label(); // Publicado
echo $status->isVisible() ? 'visible' : 'oculto';
// Persistencia directa:
// INSERT INTO posts (status) VALUES ('{$status->value}')
// $status = PostStatus::from($row['status']);
// ============================================================
// PARTE 2: CUÁNDO EL ENUM SE ROMPE
// ============================================================
// Imagina un pedido con cuatro estados. Parece razonable usar un Enum...
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
}
// El negocio pide que "cancelar" haga cosas distintas según el estado:
// - Pending ? solo cambiar estado
// - Paid ? reembolsar + devolver stock
// - Shipped ? no se puede cancelar
// - Cancelled ? ya está cancelado
// El Enum empieza a acumular dependencias externas. Problema:
/*
enum OrderStatus: string
{
public function cancel(
Order $order,
PaymentGateway $gateway, // ? dependencia externa en el Enum!
InventoryManager $inventory // ? otra más
): void {
match ($this) {
self::Pending => $order->updateStatus(self::Cancelled),
self::Paid => $this->refundAndRestock($order, $gateway, $inventory),
self::Shipped => throw new LogicException('No se puede cancelar un pedido enviado.'),
self::Cancelled => throw new LogicException('El pedido ya está cancelado.'),
};
}
}
*/
// Resultado: el Enum sabe demasiado. Las dependencias se cuelan,
// la lógica se repite en ship(), pay()... y añadir un nuevo estado
// obliga a modificar todos los métodos existentes.
// ============================================================
// PARTE 3: STATE PATTERN CADA ESTADO COMO UNA CLASE
// ============================================================
// Interfaz del contrato de workflow. Define qué acciones puede hacer un pedido.
interface OrderState
{
public function pay(Order $order): void;
public function cancel(Order $order): void;
public function ship(Order $order): void;
public function toEnum(): OrderStatus; // para persistencia
}
// ---- Estado: Pending ----
readonly class PendingState implements OrderState
{
public function pay(Order $order): void
{
// Solo cambia el estado; el pago real lo gestiona el servicio de cobro.
$order->transitionTo(new PaidState());
}
public function cancel(Order $order): void
{
$order->transitionTo(new CancelledState());
}
public function ship(Order $order): void
{
throw new LogicException('No se puede enviar un pedido sin pagar.');
}
public function toEnum(): OrderStatus
{
return OrderStatus::Pending;
}
}
// ---- Estado: Paid con dependencias externas inyectadas ----
readonly class PaidState implements OrderState
{
public function __construct(
private PaymentGateway $gateway = new NullPaymentGateway(),
private InventoryManager $inventory = new NullInventoryManager(),
) {}
public function pay(Order $order): void
{
throw new LogicException('El pedido ya está pagado.');
}
public function cancel(Order $order): void
{
// Aquí sí hay efectos secundarios, pero están encapsulados en este estado.
$this->gateway->refund($order->paymentId);
$this->inventory->restock($order->items);
$order->transitionTo(new CancelledState());
}
public function ship(Order $order): void
{
$order->transitionTo(new ShippedState());
}
public function toEnum(): OrderStatus
{
return OrderStatus::Paid;
}
}
// ---- Estado: Shipped ----
readonly class ShippedState implements OrderState
{
public function pay(Order $order): void
{
throw new LogicException('El pedido ya fue pagado y enviado.');
}
public function cancel(Order $order): void
{
throw new LogicException('No se puede cancelar un pedido ya enviado.');
}
public function ship(Order $order): void
{
throw new LogicException('El pedido ya fue enviado.');
}
public function toEnum(): OrderStatus
{
return OrderStatus::Shipped;
}
}
// ---- Estado: Cancelled ----
readonly class CancelledState implements OrderState
{
public function pay(Order $order): void
{
throw new LogicException('No se puede pagar un pedido cancelado.');
}
public function cancel(Order $order): void
{
throw new LogicException('El pedido ya está cancelado.');
}
public function ship(Order $order): void
{
throw new LogicException('No se puede enviar un pedido cancelado.');
}
public function toEnum(): OrderStatus
{
return OrderStatus::Cancelled;
}
}
// ============================================================
// PARTE 4: MODELO ORDER DELEGA EN EL ESTADO ACTUAL
// ============================================================
class Order
{
private OrderState $state;
public int $paymentId = 0;
public array $items = [];
public function __construct(
private readonly OrderStateFactory $factory,
OrderStatus $initialStatus = OrderStatus::Pending,
) {
$this->state = $factory->make($initialStatus);
}
// La lógica de cada acción vive en el estado, no en Order.
public function pay(): void { $this->state->pay($this); }
public function cancel(): void { $this->state->cancel($this); }
public function ship(): void { $this->state->ship($this); }
public function transitionTo(OrderState $newState): void
{
$this->state = $newState;
}
// Para persistencia: guardamos el valor del Enum, no el objeto estado.
public function getStatus(): OrderStatus
{
return $this->state->toEnum();
}
}
// ============================================================
// PARTE 5: FACTORY RESUELVE DEPENDENCIAS SEGÚN EL ESTADO
// ============================================================
readonly class OrderStateFactory
{
public function __construct(
private PaymentGateway $gateway,
private InventoryManager $inventory,
) {}
public function make(OrderStatus $status): OrderState
{
return match ($status) {
OrderStatus::Pending => new PendingState(),
OrderStatus::Paid => new PaidState($this->gateway, $this->inventory),
OrderStatus::Shipped => new ShippedState(),
OrderStatus::Cancelled => new CancelledState(),
};
}
}
// ============================================================
// PARTE 6: INTERFACES DE SERVICIOS EXTERNOS Y STUBS
// ============================================================
interface PaymentGateway
{
public function refund(int $paymentId): void;
}
interface InventoryManager
{
public function restock(array $items): void;
}
// Implementaciones nulas para estados que no las necesitan:
class NullPaymentGateway implements PaymentGateway
{
public function refund(int $paymentId): void {} // no-op
}
class NullInventoryManager implements InventoryManager
{
public function restock(array $items): void {} // no-op
}
// Implementaciones reales (en producción vendrían del contenedor DI):
class StripeGateway implements PaymentGateway
{
public function refund(int $paymentId): void
{
// Llamada real a Stripe API...
echo "Reembolso procesado para payment_id={$paymentId}";
}
}
class WarehouseManager implements InventoryManager
{
public function restock(array $items): void
{
foreach ($items as $item) {
echo "Stock repuesto: {$item}";
}
}
}
// ============================================================
// PARTE 7: USO COMPLETO
// ============================================================
$factory = new OrderStateFactory(
new StripeGateway(),
new WarehouseManager(),
);
// Pedido nuevo en estado Pending:
$order = new Order($factory, OrderStatus::Pending);
$order->items = ['Teclado', 'Ratón'];
$order->paymentId = 0;
echo $order->getStatus()->value; // pending
// El cliente paga:
$order->pay();
$order->paymentId = 42;
echo $order->getStatus()->value; // paid
// El cliente cancela (dispara reembolso + restock automáticamente):
$order->cancel();
echo $order->getStatus()->value; // cancelled
// Intentar cancelar de nuevo lanza excepción:
try {
$order->cancel();
} catch (LogicException $e) {
echo $e->getMessage(); // El pedido ya está cancelado.
}
// Rehidratar desde BBDD:
$statusFromDb = OrderStatus::from('paid'); // viene de un SELECT
$orderFromDb = new Order($factory, $statusFromDb);
// El Order ya sabe que está en Paid y se comportará en consecuencia.
// ============================================================
// PARTE 8: TEST UNITARIO DEL ESTADO AISLADO
// ============================================================
// Con el State Pattern se puede testear cada estado sin montar
// el Order completo ni la factory.
class FakeOrder
{
public ?OrderState $lastTransition = null;
public int $paymentId = 99;
public array $items = ['A', 'B'];
public function transitionTo(OrderState $state): void
{
$this->lastTransition = $state;
}
}
class FakeGateway implements PaymentGateway
{
public int $refundCalls = 0;
public function refund(int $paymentId): void
{
$this->refundCalls++;
}
}
class FakeInventory implements InventoryManager
{
public int $restockCalls = 0;
public function restock(array $items): void
{
$this->restockCalls++;
}
}
// Test: PaidState.cancel() llama a refund, restock y transiciona a Cancelled.
$gateway = new FakeGateway();
$inventory = new FakeInventory();
$paidState = new PaidState($gateway, $inventory);
$fakeOrder = new FakeOrder();
$paidState->cancel($fakeOrder);
assert($gateway->refundCalls === 1, 'Debería haber llamado a refund una vez');
assert($inventory->restockCalls === 1, 'Debería haber llamado a restock una vez');
assert($fakeOrder->lastTransition instanceof CancelledState, 'Debería transicionar a CancelledState');
echo "Todos los assertions pasaron.";
// ============================================================
// CUÁNDO USAR CADA ENFOQUE RESUMEN
// ============================================================
/*
* USA ENUM cuando:
* - El estado es una etiqueta (draft/published, activo/inactivo)
* - Las transiciones son simples y sin efectos secundarios
* - Solo necesitas filtrar, mostrar o persistir el valor
* Ejemplos: estado de un post, visibilidad, rol de usuario
*
* USA STATE PATTERN cuando:
* - Cada estado tiene comportamiento distinto
* - Las transiciones llaman a servicios externos
* - La lógica condicional se repite en varios métodos
* - Hay nuevos estados previstos en el futuro
* Ejemplos: pedidos, pagos, suscripciones, pipelines de aprobación
*
* COMBÍNALOS cuando:
* - Necesitas persistir en BBDD (Enum) pero quieres lógica aislada (State)
* - Usa Enum->value para guardar y State Objects para operar
* - La Factory convierte Enum ? State al rehidratar desde BBDD
*/
State Pattern vs. Enums en PHP moderno: cuándo usar cada uno
Los Enums de PHP 8.1 son geniales para estados simples, pero cuando el negocio crece y cada estado necesita comportamiento propio reembolsos, llamadas a APIs, validaciones distintas el Enum revienta. Este tutorial muestra cómo pasar al State Pattern con clases dedicadas, inyección de dependencias y una Factory que convierte Enum en State Object al rehidratar desde base de datos. Incluye tests unitarios de cada estado por separado.
Descargar adjuntos
COMPARTE ESTE TUTORIAL
COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP