---
name: php-patrones
description: >
  Patrones Laravel modernos: Action classes, Service classes, DTOs readonly,
  Pipeline, Repository con interfaces, Enums, Value Objects y Feature Flags.
  Cargar cuando se diseñe arquitectura Laravel o se refactoricen controllers.
version: "1.0.0"
herramientasPermitidas: [Read, Glob, Grep]
exclusiones:
  - "No cargar para implementar controllers, modelos Eloquent o Jobs concretos — para implementación cargar `php-experto`."
  - "No cargar para escribir tests PHPUnit o Pest — para testing cargar `php-testing`."
  - "No cargar para errores de Composer o extensiones PHP — para build errors cargar `build-errors-php`."
  - "No cargar para patrones de diseño genéricos sin contexto Laravel — este skill cubre Action classes, Service classes y Value Objects en el contexto del ecosistema Laravel."
evolvable: true  # default para skill estandar
---
# PHP Patrones — Arquitectura Laravel Moderna

Los controladores de Laravel son puntos de entrada HTTP, no contenedores de
lógica de negocio. Este skill cubre los patrones que mantienen el código
organizado, testeable y mantenible a medida que la aplicación crece.

## Cuándo cargar este skill

Invoca `Skill("php-patrones")` cuando:

- Se refactorice un controller con más de 50 líneas de lógica
- Se diseñe la arquitectura de un módulo nuevo
- Se implemente un flujo con múltiples pasos secuenciales
- Se requieran DTOs tipados para transferir datos entre capas
- Se evalúen feature flags con Laravel Pennant

## Cuándo NO cargar

- La pregunta es sobre implementar controllers, modelos Eloquent o Jobs concretos — para implementación cargar `php-experto`.
- La tarea es escribir tests PHPUnit o Pest — cargar `php-testing`.
- Los errores son de Composer o extensiones PHP — cargar `build-errors-php`.
- La discusión es sobre patrones GoF genéricos sin contexto Laravel — este skill cubre Action classes, Service classes y Value Objects en el ecosistema Laravel.

## Conceptos clave

### Action Classes — una clase, una operación

Una Action encapsula una operación de negocio específica. Es testeable de forma
aislada, reutilizable desde controllers, jobs y commands, y su nombre documenta
exactamente lo que hace.

### Pipeline Pattern

El Pipeline de Laravel permite encadenar operaciones sobre un objeto de forma
funcional. Cada etapa (pipe) recibe el objeto, lo transforma y lo pasa al siguiente.
Ideal para flujos con validaciones, transformaciones y efectos secundarios.

### DTOs con readonly

Los Data Transfer Objects con `readonly` en PHP 8.1+ son inmutables por definición.
Eliminan setters, garantizan que los datos no cambian después de construirse y
self-documentan la estructura de datos que fluye entre capas.

## Reglas obligatorias

### Actions: una responsabilidad, un método público `ejecutar()`

```php
// MAL — Service con multiples responsabilidades mezcladas
class PedidoService {
    public function crear(array $datos): Pedido { ... }
    public function cancelar(Pedido $pedido): void { ... }
    public function calcularDescuento(Pedido $pedido): float { ... }
    public function enviarNotificacion(Pedido $pedido): void { ... }
}

// BIEN — Actions separadas y cohesivas
class CrearPedidoAction {
    public function ejecutar(CrearPedidoDTO $dto): Pedido { ... }
}
class CancelarPedidoAction {
    public function ejecutar(Pedido $pedido, string $motivo): void { ... }
}
```

### DTOs readonly para datos de entrada

```php
// MAL — array anonimo sin tipado
public function ejecutar(array $datos): Pedido
{
    $nombre = $datos['nombre'] ?? null; // sin autocomplete, sin tipos
}

// BIEN — DTO readonly tipado
readonly class CrearPedidoDTO
{
    public function __construct(
        public string  $nombre,
        public float   $total,
        public ?string $cuponDescuento,
        public array   $items,
    ) {}

    public static function desdeRequest(StorePedidoRequest $request): self
    {
        return new self(
            nombre:          $request->validated('nombre'),
            total:           0.0, // calculado luego
            cuponDescuento:  $request->validated('cupon'),
            items:           $request->validated('items'),
        );
    }
}
```

### Repository con interface para abstraer el ORM

```php
interface PedidoRepositoryInterface
{
    public function encontrarPorId(int $id): ?Pedido;
    public function listarPendientes(): Collection;
    public function guardar(Pedido $pedido): Pedido;
}

class EloquentPedidoRepository implements PedidoRepositoryInterface
{
    public function encontrarPorId(int $id): ?Pedido
    {
        return Pedido::with(['items', 'cliente'])->find($id);
    }
    // ...
}

// Binding en AppServiceProvider
$this->app->bind(PedidoRepositoryInterface::class, EloquentPedidoRepository::class);
```

**Por qué**: Permite swapear la implementación (ej: agregar cache) sin tocar
la lógica de negocio. Facilita mocking en tests.

## Patrones recomendados

### Pipeline para flujos multi-etapa

```php
class ProcesarPedidoAction
{
    public function ejecutar(Pedido $pedido): Pedido
    {
        return app(Pipeline::class)
            ->send($pedido)
            ->through([
                ValidarStockPipe::class,
                AplicarDescuentoPipe::class,
                CalcularEnvioPipe::class,
                ReservarInventarioPipe::class,
            ])
            ->thenReturn();
    }
}

class ValidarStockPipe
{
    public function handle(Pedido $pedido, Closure $next): Pedido
    {
        foreach ($pedido->items as $item) {
            throw_unless($item->producto->tieneStock($item->cantidad),
                new StockInsuficienteException($item->producto));
        }
        return $next($pedido);
    }
}
```

### Enum casting en modelos

```php
// Definir el enum (PHP 8.1+)
enum EstatusPedido: string
{
    case Pendiente  = 'pendiente';
    case Pagado     = 'pagado';
    case Cancelado  = 'cancelado';

    public function puedeTransicionarA(self $nuevo): bool
    {
        return match($this) {
            self::Pendiente => in_array($nuevo, [self::Pagado, self::Cancelado]),
            default         => false,
        };
    }
}

// Castear en el modelo
class Pedido extends Model
{
    protected $casts = [
        'estatus' => EstatusPedido::class,
    ];
}

// Uso con tipado fuerte
if ($pedido->estatus->puedeTransicionarA(EstatusPedido::Cancelado)) {
    $pedido->update(['estatus' => EstatusPedido::Cancelado]);
}
```

### Laravel Pennant para feature flags

```php
// Definir el flag en AppServiceProvider
Feature::define('nuevo-checkout', fn (User $user) =>
    $user->created_at->isAfter(now()->subDays(30))
);

// Verificar en el codigo
if (Feature::active('nuevo-checkout')) {
    return new NuevoCheckoutAction();
}

// En Blade
@feature('nuevo-checkout')
    <x-nuevo-checkout />
@endfeature
```

### Value Objects para conceptos del dominio

```php
readonly class Dinero
{
    public function __construct(
        public readonly float  $monto,
        public readonly string $moneda = 'MXN',
    ) {
        if ($monto < 0) {
            throw new \InvalidArgumentException("El monto no puede ser negativo");
        }
    }

    public function sumar(self $otro): self
    {
        if ($this->moneda !== $otro->moneda) {
            throw new MonedaIncompatibleException();
        }
        return new self($this->monto + $otro->monto, $this->moneda);
    }

    public function __toString(): string
    {
        return number_format($this->monto, 2) . ' ' . $this->moneda;
    }
}
```

## Anti-patrones conocidos

### Fat Controllers — NUNCA más de 10 líneas de lógica por método

Un controller que hace queries, calcula totales, envía emails y retorna respuesta
es imposible de testear y mantener. Delegar a Actions o Services.

### Model Callbacks para lógica compleja

```php
// MAL — logica oculta en boot() del modelo
protected static function boot(): void
{
    static::created(function (Pedido $pedido) {
        // envia email, actualiza inventario, crea factura...
    });
}

// BIEN — logica explicita en un Event Listener o Action
// El caller sabe que estas cosas suceden porque las ve en el codigo
```

### Helpers globales para lógica de negocio

Los `helpers.php` globales son difíciles de testear y crean acoplamiento implícito.
Usar classes estaticas o inyectables en su lugar.

## Checklist de verificación

- [ ] Controllers delegan a Actions o Services (< 15 líneas por método)
- [ ] DTOs readonly para datos de entrada tipados
- [ ] Enums en lugar de strings mágicos para estados
- [ ] Repository interface si la lógica de acceso a datos es compleja
- [ ] Pipeline para flujos con más de 3 pasos secuenciales
- [ ] Feature flags para funcionalidades en desarrollo progresivo

## Referencias

- [Laravel Service Container](https://laravel.com/docs/container)
- [PHP 8.1 Enums](https://www.php.net/manual/es/language.enumerations.php)
- [Laravel Pennant](https://laravel.com/docs/pennant)

## Gotchas / Errores comunes no obvios

**Action class que lanza excepción en lugar de retornar `Result` o DTO rompe el flujo de la pipeline**: si una Action usa `throw new ValidationException(...)` internamente pero la pipeline espera un valor de retorno tipado, el calling code necesita un `try/catch` duplicado en cada lugar. Fix: definir la Action con retorno `Result<T>` o con un DTO de respuesta que incluya el estado de éxito/fallo — reservar las excepciones solo para errores inesperados.

**Pipeline de Laravel que modifica `$passable` por referencia en lugar de retornar el nuevo valor no funciona con `Closure::class`**: si un pipe en la pipeline modifica el objeto pasado en lugar de retornar el resultado del `$next($passable)`, los pipes subsecuentes reciben el objeto original sin las modificaciones. Fix: siempre retornar `$next($passable)` desde cada pipe — la pipeline de Laravel es un chain de decorators, no una lista de efectos secundarios.

**DTO `readonly` con propiedades de tipo objeto mutable no es realmente inmutable**: `readonly class FacturaDto { public function __construct(public readonly array $items) {} }` — `$dto->items` no puede reasignarse, pero si `$items` contiene objetos mutables, esos objetos pueden modificarse. Fix: usar `array_map` para crear copias de los objetos contenidos, o usar colecciones inmutables con tipos `readonly` anidados.

**Feature flag con `Pennant::for($usuario)->active('flag')` no funciona en un Job sin el usuario en contexto**: dentro de un Job, el usuario autenticado no está disponible automáticamente — `Pennant::for(Auth::user())` retorna null si no se pasó el usuario explícitamente al job. Fix: pasar el usuario como propiedad del job en el constructor y usar `Pennant::for($this->usuario)->active('flag')` dentro del job.

---
*Skill creado con swl:crear-skill el 2026-03-31. Versión 1.0.0.*
