php elephant sticker
Photo by RealToughCandy.com on Pexels.com
目次

[Guía práctica completa] Diseño orientado a eventos en Laravel — Event, Listener, Subscriber, Broadcasting, separación de efectos secundarios, pruebas y diseño accesible de notificaciones

Lo que aprenderás en este artículo (puntos clave)

  • Los roles de Laravel Event / Listener / Subscriber, y los criterios para decidir hasta qué punto conviene diseñar con eventos
  • Cómo separar limpiamente efectos secundarios como “enviar un correo, actualizar inventario, guardar un registro de auditoría después de crear un pedido”
  • Cómo elegir entre eventos síncronos y ejecución en cola, cómo pensar en los fallos y los fundamentos de la idempotencia
  • Cómo organizar el diseño para no confundir eventos de dominio con eventos del framework
  • Cómo conectar eventos con broadcasting y notificaciones, y las consideraciones de diseño para actualizaciones en vivo y accesibilidad
  • Estrategias de prueba para Event / Listener, además de ideas de logging y monitoreo que mantengan la operación manejable

Lectores previstos (quién se beneficia)

  • Ingenieros Laravel de nivel principiante a intermedio: personas cuyos controladores y servicios han acumulado demasiados efectos secundarios y se han vuelto difíciles de cambiar con seguridad
  • Tech leads: personas que quieren introducir diseño orientado a eventos sin caer en la sobreingeniería ni perder visibilidad
  • Personal de QA / mantenimiento: personas que quieren organizar efectos secundarios como el envío de correos y los registros de auditoría en formas más fáciles de probar y menos frágiles
  • Diseñadores / personal de CS / accesibilidad: personas que quieren diseñar notificaciones y actualizaciones en vivo de una manera fácil de entender para todos

Nivel de accesibilidad: ★★★★★
El diseño orientado a eventos en sí es arquitectura de backend, pero en la práctica afecta directamente la calidad de las notificaciones en pantalla, las actualizaciones en vivo y los mensajes de finalización. En este artículo también organizamos ideas sobre role="status", role="alert", visualización de estados que no dependan solo del color y políticas para evitar actualizaciones automáticas excesivas.


1. Introducción: los eventos no son una “arquitectura genial”, sino herramientas para organizar efectos secundarios

A medida que continúas desarrollando con Laravel, tarde o temprano llegarás a situaciones en las que querrás ejecutar varias acciones adicionales después de que se complete un proceso principal. Por ejemplo, después de crear un pedido, quizá quieras enviar un correo de confirmación, reducir inventario, guardar un registro de auditoría, notificar a administradores y tal vez sincronizar con un sistema externo. Al principio, puedes simplemente escribirlas una tras otra al final de un controlador o servicio, y funcionará. Pero a medida que las funciones aumentan, el “proceso principal” y los “efectos secundarios” se mezclan, y se vuelve más difícil ver qué debe cambiarse y dónde.

Aquí es donde el diseño orientado a eventos se vuelve útil. Un evento representa el hecho en el centro del proceso, es decir, lo que ocurrió, y te permite separar las acciones posteriores que reaccionan a ello. El punto importante no es usar diseño orientado a eventos porque esté de moda, sino usarlo para organizar efectos secundarios y mejorar la mantenibilidad. En este artículo, tomaremos esa idea y la traduciremos a un uso práctico en Laravel centrado en Event / Listener, de una forma más fácil de aplicar en trabajo real.


2. Primero, entiende esto: un evento es “algo que ocurrió”, y un listener es “algo que reacciona”

Los eventos de Laravel son fáciles de entender si los piensas de forma muy simple.

  • Event
    • Representa el hecho de que algo ocurrió
    • Ejemplos: OrderPlaced, UserRegistered, ArticlePublished
  • Listener
    • El proceso que reacciona a ese hecho
    • Ejemplos: enviar un correo de confirmación, escribir un registro de auditoría, actualizar inventario

Lo bueno de esta división es que te permite separar el propósito principal —como “crear un pedido”— de “todo lo que debería ocurrir después de que el pedido se haya creado”. Por ejemplo, si la creación del pedido tiene éxito y si el envío del correo tiene éxito son preocupaciones realmente distintas. Si todo está metido en un solo método, cuando una parte falla, el impacto tiende a ser mucho mayor.

Dicho eso, no todo debería convertirse en un evento. Los eventos funcionan mejor en casos donde varios efectos secundarios dependen de un solo hecho ocurrido. Por otro lado, si empujas el proceso principal en sí, o un procesamiento obligatorio fuertemente acoplado, completamente a eventos, el flujo del código se vuelve más difícil de leer. Más adelante organizaremos criterios para esa decisión.


3. Pensemos en un ejemplo típico: dividir la creación de pedidos en diseño orientado a eventos

Primero, consideremos una implementación directa sin eventos.

public function store(StoreOrderRequest $request, CreateOrderAction $action)
{
    $order = $action->execute($request->user(), $request->validated());

    Mail::to($order->user->email)->queue(new OrderPlacedMail($order->id));
    app(InventoryService::class)->decreaseByOrder($order);
    AuditLog::create([
        'actor_user_id' => $request->user()->id,
        'action' => 'order.created',
        'target_type' => Order::class,
        'target_id' => $order->id,
    ]);

    return redirect()
        ->route('orders.show', $order)
        ->with('status', 'Your order has been received.');
}

Al principio este código es fácil de entender, pero una vez que agregas más destinos de notificación o integraciones externas, rápidamente se vuelve largo. Así que, en su lugar, tratemos “se ha realizado un pedido” como un evento y separemos los efectos secundarios que le siguen.

3.1 Definir el Event

namespace App\Events;

use App\Models\Order;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderPlaced
{
    use Dispatchable, SerializesModels;

    public function __construct(public Order $order)
    {
    }
}

3.2 Mantener simple el lado que dispara

public function store(StoreOrderRequest $request, CreateOrderAction $action)
{
    $order = $action->execute($request->user(), $request->validated());

    event(new \App\Events\OrderPlaced($order));

    return redirect()
        ->route('orders.show', $order)
        ->with('status', 'Your order has been received.');
}

3.3 Separar los Listeners

namespace App\Listeners;

use App\Events\OrderPlaced;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderPlacedMail;

class SendOrderPlacedMail implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->user->email)
            ->queue(new OrderPlacedMail($event->order->id));
    }
}
namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Services\InventoryService;

class DecreaseInventory
{
    public function __construct(
        private InventoryService $inventoryService
    ) {}

    public function handle(OrderPlaced $event): void
    {
        $this->inventoryService->decreaseByOrder($event->order);
    }
}
namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Models\AuditLog;
use App\Models\Order;

class WriteOrderAuditLog
{
    public function handle(OrderPlaced $event): void
    {
        AuditLog::create([
            'actor_user_id' => $event->order->user_id,
            'action' => 'order.created',
            'target_type' => Order::class,
            'target_id' => $event->order->id,
        ]);
    }
}

Con esta forma, la creación del pedido en sí y los efectos secundarios posteriores quedan claramente separados. Los controladores y Actions se vuelven más cortos, y el rango de impacto se vuelve más fácil de leer.


4. Procesamiento que encaja con eventos, y procesamiento que no

Que el diseño orientado a eventos sea útil no significa que todo deba convertirse en evento. Si conviertes todo en eventos, puede volverse más difícil de entender. Por eso conviene tener criterios de decisión desde el principio.

Procesamiento que encaja con eventos

  • Varios efectos secundarios dependen de un hecho ocurrido
  • Los efectos secundarios pueden permanecer débilmente acoplados
  • Algunos procesos pueden añadirse o quitarse más adelante
  • Procesamiento auxiliar como notificaciones, registros de auditoría, sincronización externa y actualización del índice de búsqueda
  • Procesamiento que puede retrasarse un poco y es adecuado para colas

Procesamiento que no encaja con eventos

  • El proceso principal en sí
  • Procesamiento cuyo éxito y fallo deben permanecer absolutamente juntos
  • Procesamiento que debe completarse estrictamente dentro de una transacción
  • Procesamiento con un orden o consistencia muy estrictos, donde una separación invisible sería riesgosa

Por ejemplo, “crear un pedido” en sí es el proceso principal. En cambio, “notificar al administrador después de crear el pedido” es un efecto secundario. Separarlos hace que el diseño sea más limpio. Pero “crear el registro del pedido”, “crear los ítems del pedido” y “guardar el importe total” normalmente son más naturales como un único proceso de negocio dentro de un Action o Service.


5. Una forma de evitar confundir eventos de dominio y eventos de Laravel

Un punto que puede volverse un poco confuso en la práctica es que “eventos de dominio” y “eventos de Laravel” no siempre son exactamente lo mismo.

  • Evento de dominio
    • Un hecho con significado de negocio
    • Ejemplos: OrderPlaced, SubscriptionRenewed, UserSuspended
  • Evento del framework
    • Eventos proporcionados internamente por Laravel, o eventos por motivos técnicos
    • Ejemplos: inicio de sesión exitoso, fallo de un job, hooks del ciclo de vida del modelo

Al principio, lo más fácil es no complicarlo demasiado y simplemente usar hechos con significado de negocio como nombres de eventos. OrderPlaced es mucho más fácil de entender que OrderSaved, y más amable para la siguiente persona que lea el código.
Es mejor mantener los eventos técnicos conceptualmente separados de los eventos de negocio, tanto en nombres como en manejo, para reducir la confusión.


6. Poner Listeners en cola: no hagas síncronos los efectos secundarios pesados

Una vez que adoptas diseño orientado a eventos, resulta tentador meter procesamiento pesado dentro de los listeners. Pero efectos secundarios como envío de correos, integración con APIs externas, generación de imágenes y agregaciones pueden ralentizar la request principal si se ejecutan de forma síncrona. Por eso usar ShouldQueue para enviar el listener mismo a la cola es un diseño tan eficaz.

class SendOrderPlacedMail implements ShouldQueue
{
    public int $tries = 5;
    public int $timeout = 60;

    public function backoff(): array
    {
        return [10, 30, 60, 120];
    }

    public function handle(OrderPlaced $event): void
    {
        Mail::to($event->order->user->email)
            ->queue(new OrderPlacedMail($event->order->id));
    }
}

Esto permite que la creación principal del pedido termine rápidamente, mientras las acciones de seguimiento continúan de forma asíncrona. Operativamente, también se vuelve más fácil monitorear jobs fallidos y Horizon, lo que facilita distinguir entre el proceso principal y los efectos secundarios.

Sin embargo, es arriesgado mover procesamiento a la cola si realmente debe terminar junto con la creación del pedido. Es más seguro decidir qué permanece síncrono y qué se vuelve asíncrono según el impacto al usuario y las necesidades de consistencia.


7. Cómo pensar los fallos: decidir si los fallos del listener deben afectar al proceso principal

Una parte importante del diseño orientado a eventos es decidir por adelantado si si un proceso posterior falla, el proceso principal también debería fallar.
Por ejemplo, si falla la notificación al administrador después de crear un pedido, a menudo querrás que el pedido en sí siga siendo válido. Pero si falla la actualización del inventario, el pedido puede volverse riesgoso. Esto no es algo que deba decidirse de forma uniforme; debes pensarlo según cada proceso.

Ejemplo de esta organización

  • Creación de pedido
    • Obligatorio: el pedido en sí, los detalles del pedido, confirmación de pago
    • Importante pero posterior: actualización de inventario
    • Auxiliar: correo de confirmación, registro de auditoría, notificación al administrador
  • Registro de usuario
    • Obligatorio: creación del usuario
    • Posterior: correo de bienvenida, evento analítico, sincronización con CRM

Cuando lo divides así, se vuelve más fácil ver qué pertenece dentro de la transacción y qué debería separarse mediante eventos.


8. Idempotencia: los eventos y listeners deben seguir siendo seguros aunque se vuelvan a ejecutar

Los eventos y colas pueden reintentarse o dispararse más de una vez. Por eso es más seguro diseñar listeners de forma que no se rompan cuando se ejecuten dos veces.

Por ejemplo, para correos de confirmación, tranquiliza mucho mantener un flag “enviado” para notificaciones importantes.

class SendOrderPlacedMail implements ShouldQueue
{
    public function handle(OrderPlaced $event): void
    {
        $order = $event->order->fresh();

        if ($order->confirmation_mail_sent_at) {
            return;
        }

        Mail::to($order->user->email)
            ->send(new OrderPlacedMail($order->id));

        $order->forceFill([
            'confirmation_mail_sent_at' => now(),
        ])->save();
    }
}

Con esto, incluso los reintentos o jobs duplicados pueden evitar el envío doble. El diseño orientado a eventos es potente, pero acostumbrarte a preguntar “¿qué pasa si este mismo evento se procesa dos veces?” hace que la operación sea mucho más tranquila.


9. Subscriber: dónde organizar las cosas cuando aumentan los eventos

A medida que aumentan los listeners, puede que quieras gestionar juntos grupos relacionados de eventos. Ahí es donde un Subscriber puede ayudar.
Un Subscriber es un mecanismo para reunir suscripciones a múltiples eventos dentro de una sola clase.

Por ejemplo, si quieres reunir los eventos relacionados con registros de auditoría en un solo lugar, puedes organizarlo así.

namespace App\Listeners;

use App\Events\OrderPlaced;
use App\Events\UserSuspended;
use App\Models\AuditLog;

class AuditSubscriber
{
    public function handleOrderPlaced(OrderPlaced $event): void
    {
        AuditLog::create([
            'action' => 'order.created',
            'target_id' => $event->order->id,
        ]);
    }

    public function handleUserSuspended(UserSuspended $event): void
    {
        AuditLog::create([
            'action' => 'user.suspended',
            'target_id' => $event->user->id,
        ]);
    }

    public function subscribe($events): void
    {
        $events->listen(
            OrderPlaced::class,
            [self::class, 'handleOrderPlaced']
        );

        $events->listen(
            UserSuspended::class,
            [self::class, 'handleUserSuspended']
        );
    }
}

No necesitas convertir todo en Subscriber, pero usar uno solo para cosas que quedan más claras agrupadas puede hacer la estructura mucho más limpia.


10. Cosas a vigilar al conectar con Broadcasting o notificaciones en tiempo real

Los eventos también pueden combinarse con notificaciones en pantalla en vivo y broadcasting. Por ejemplo, hay casos en los que quieres mostrar cosas como “exportación completada”, “ha llegado un nuevo pedido” o “se ha añadido un comentario” en tiempo real en la pantalla.

Sin embargo, aunque las actualizaciones en tiempo real son convenientes, debes tener cuidado con la accesibilidad y la carga cognitiva.

  • No dejes que la pantalla cambie drásticamente por sí sola
  • No conviertas actualizaciones no importantes en alerts
  • Muestra claramente números y cantidades en texto
  • No distingas estados solo por color
  • Incluso si hay actualizaciones automáticas, no robes el foco del usuario

Ejemplo mínimo de UI

<div id="status" role="status" aria-live="polite" class="sr-only"></div>

Cuando llega una notificación en tiempo real, coloca en esta área una frase corta pero con sentido.
Un mal ejemplo sería solo “Actualizado”.
Un buen ejemplo sería algo como “Se ha añadido un nuevo pedido” o “La exportación se ha completado. Ya puedes descargarla”, donde la siguiente acción queda clara.


11. El diseño de eventos y el diseño accesible de notificaciones funcionan bien juntos

Una vez que el diseño orientado a eventos está organizado, se vuelve más fácil ver qué debe notificarse en la interfaz. Por ejemplo, si existe un evento llamado ExportFinished, entonces la pantalla debería mostrar una “notificación de finalización”. Si existe un UserSuspended, entonces la pantalla de administración puede mostrar “El usuario ha sido suspendido” usando role="status".

En otras palabras, cuanto más claramente estén organizados los hechos del backend, menos inconsistente se vuelve el diseño de estados de la UI. Como resultado, se vuelve posible este tipo de consistencia.

  • Éxito: role="status"
  • Falla importante: role="alert"
  • Texto que no dependa solo del color
  • Mensajes que dejen claros los reintentos o la siguiente acción

Esta consistencia ayuda mucho no solo a los usuarios finales, sino también a QA y soporte al cliente.


12. Logging y monitoreo: los eventos son más difíciles de ver, así que la observabilidad importa

Una debilidad del diseño orientado a eventos es que el flujo de procesamiento se vuelve más difícil de rastrear directamente en el código. Por eso tranquiliza tener al menos logs y monitoreo mínimos.

Para eventos importantes, es útil dejar logs estructurados con información como:

  • nombre del evento
  • ID del objetivo (order_id, user_id, etc.)
  • trace_id
  • nombre del listener que se ejecutó
  • resultado (success / failed)

Cuando investigas jobs fallidos o notificaciones no enviadas, se vuelve mucho más fácil si puedes ver de qué evento se originaron.


13. Pruebas: cómo pensar las pruebas de eventos

El diseño orientado a eventos puede verse limpio, pero sin pruebas es difícil confiar en él. En Laravel, Event::fake() y las pruebas individuales de listeners son muy útiles.

13.1 Probar si el evento fue despachado

use Illuminate\Support\Facades\Event;

public function test_order_placed_event_is_dispatched()
{
    Event::fake();

    $user = User::factory()->create();

    $response = $this->actingAs($user)->post('/orders', [
        'total_amount' => 1000,
        'items' => [
            ['product_id' => 1, 'quantity' => 1, 'price' => 1000],
        ],
    ]);

    $response->assertRedirect();

    Event::assertDispatched(\App\Events\OrderPlaced::class);
}

13.2 Probar el listener de forma individual

public function test_send_order_mail_listener_marks_sent_at()
{
    $order = Order::factory()->create([
        'confirmation_mail_sent_at' => null,
    ]);

    Mail::fake();

    $listener = app(\App\Listeners\SendOrderPlacedMail::class);
    $listener->handle(new \App\Events\OrderPlaced($order));

    $this->assertNotNull($order->fresh()->confirmation_mail_sent_at);
}

Separar la prueba de “despacho del evento” de la prueba del listener hace más fácil identificar qué se rompió realmente.


14. Errores comunes y cómo evitarlos

14.1 Convertir todo en eventos y perder visibilidad

Una buena manera de evitarlo es mantener el proceso principal dentro de un Action o Service, y separar solo los efectos secundarios en eventos.

14.2 Que los nombres de eventos se vuelvan demasiado técnicos para entenderse

Usa nombres que comuniquen significado de negocio, como OrderPlaced en lugar de OrderSaved, para que el código sea más fácil de leer.

14.3 Que los listeners se vuelvan demasiado pesados y ralenticen el proceso principal

El procesamiento pesado debería ir a cola con ShouldQueue en lugar de manejarse de forma síncrona.

14.4 Que los reintentos causen notificaciones duplicadas o integraciones duplicadas

Puedes crear seguridad con flags de envío, restricciones únicas, locks y estrategias similares de idempotencia.

14.5 No conocer el rango de impacto cuando algo falla

Si registras nombres de eventos, IDs de objetivos y trace_ids, y monitoreas mediante Horizon o seguimiento de excepciones, las investigaciones se vuelven mucho más rápidas.


15. Checklist (para distribución)

Diseño

  • [ ] El proceso principal y los efectos secundarios se consideran por separado
  • [ ] Los nombres de los eventos expresan hechos de negocio
  • [ ] Cada listener tiene un solo propósito y se mantiene corto
  • [ ] No todo está eventizado; el proceso principal permanece en un Service / Action

Confiabilidad

  • [ ] Los listeners pesados están en cola
  • [ ] El diseño tiene en cuenta la idempotencia
  • [ ] Está claro si los fallos del listener deben afectar al proceso principal
  • [ ] Los logs conservan el nombre del evento, el ID del objetivo y el trace_id

UI / Accesibilidad

  • [ ] Las notificaciones de éxito pueden manejarse consistentemente con role="status"
  • [ ] Los fallos importantes se transmiten con role="alert"
  • [ ] Las actualizaciones en tiempo real no roban el foco
  • [ ] El estado no se transmite solo por color

Pruebas

  • [ ] Existen pruebas del despacho de eventos
  • [ ] Existen pruebas individuales para los listeners importantes
  • [ ] Existen pruebas de idempotencia y prevención de ejecución duplicada

16. Conclusión

El diseño orientado a eventos en Laravel no está pensado para hacer el código más difícil. Es una herramienta para separar el proceso principal de sus efectos secundarios, de modo que el código sea más fácil de mantener. Cuando usas como eje hechos de negocio como OrderPlaced y UserRegistered, el trabajo posterior como envío de correos, registros de auditoría, sincronización externa y notificaciones puede organizarse de forma natural. Si además envías el procesamiento pesado a colas y piensas cuidadosamente en el alcance de los fallos y en la idempotencia, el diseño también se vuelve más tranquilo en operación. Y una vez que tus eventos están organizados, las notificaciones de la UI se vuelven más fáciles de mantener consistentes, lo que también facilita crear visualizaciones de estado accesibles. Un buen punto de partida es escoger solo un efecto secundario que actualmente esté al final de un controlador o servicio, e intentar extraerlo a un evento.


Enlaces de referencia

por greeden

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)