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.
				<?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
 */

			
Descargar adjuntos
COMPARTE ESTE TUTORIAL

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP