lunes, 22 de septiembre de 2025

Event Listeners síncronos y asíncronos en Laravel

 Quiero que se desencadene una tarea en respuesta a lo que ocurre en un controlador, pero de manera desacoplada. ¿Es apropiado utilizar eventos y listeners? ¿A un listener se le puede hacer type-hint del request? ¿El listener puede trabajar con los global helpers request y session?

Sí, utilizar eventos y listeners es la forma más apropiada y canónica de resolver ese problema.

Es la solución ideal para desacoplar la lógica, siguiendo el patrón de diseño Observer. En este patrón, el controlador "emite" una señal (el evento) de que algo ha ocurrido, y los "observadores" (los listeners) reaccionan a esa señal de forma independiente.

Esto te permite:

  • Mantener la lógica del controlador limpia, centrada solo en manejar la solicitud HTTP.

  • Hacer que la tarea desacoplada sea más modular y reutilizable.


¿Es apropiado utilizar eventos y listeners?

Absolutamente. Este es un caso de uso de libro para el sistema de eventos de Laravel. El controlador no necesita saber qué sucede después de que se completa su acción, solo necesita notificar que se ha realizado con éxito. Los listeners se encargan del resto, lo que te permite agregar o quitar tareas en el futuro sin modificar el controlador.

¿A un listener se le puede hacer type-hint del request?

No, no se puede. Esta es una limitación intencional y una de las reglas clave de este patrón. Un listener no recibe el objeto Request directamente, ya que su método handle solo recibe una instancia del evento.

La razón es simple pero crucial: los listeners pueden ser procesados en una cola en un proceso diferente al de la solicitud web original. En ese entorno, el objeto Request ya no existe, y tratar de acceder a él causaría un error o devolvería null.

¿El listener puede trabajar con los global helpers request() y session()?

Técnicamente, sí, pero es una muy mala práctica.

Aunque podrías llamar a request() o session() dentro de un listener, esto rompe por completo el propósito del desacoplamiento. Si el listener se ejecuta en una cola, estos helpers no tendrían un contexto HTTP y no funcionarían como esperas, lo que llevaría a errores difíciles de depurar.

La forma correcta de pasar datos

La solución correcta es pasar toda la información que el listener necesita desde el controlador a través del objeto Evento.

  1. En tu Evento: Define las propiedades que necesitas, como la información de la solicitud, el usuario o los datos del modelo.

  2. Evento Personalizado
    <?php

    namespace App\Events;

    use App\Models\User;
    use Illuminate\Foundation\Events\Dispatchable;

    class UserRegistered
    {
    use Dispatchable;

    public User $user;
    public string $ipAddress;

    public function __construct(User $user, string $ipAddress)
    {
    $this->user = $user;
    $this->ipAddress = $ipAddress;
    }
    }

2. En tu Controlador: Dispara el evento, pasando los datos necesarios.

Controlador de Registro
<?php

namespace App\Http\Controllers;

use App\Events\UserRegistered;
use Illuminate\Http\Request;
use App\Models\User;

class RegisterController extends Controller
{
public function register(Request $request)
{
// ... (lógica de validación y creación del usuario)

$user = User::create($request->validated());

// Disparamos el evento, pasando el objeto Request
UserRegistered::dispatch($user, $request->ip());

return redirect()->route('dashboard');
}
}

  1. En tu Listener: El listener recibe el objeto UserRegistered y accede a las propiedades sin depender de nada del entorno global.

  2. Listener de Bienvenida
    <?php

    namespace App\Listeners;

    use App\Events\UserRegistered;
    use App\Mail\WelcomeMail;
    use Illuminate\Support\Facades\Mail;

    class SendWelcomeEmail
    {
    public function handle(UserRegistered $event)
    {
    // Accedes a la data directamente del evento, no del request
    $user = $event->user;
    $ip = $event->ipAddress;

    // Enviar un correo, loguear la actividad, etc.
    Mail::to($user->email)->send(new WelcomeMail($user));
    }
    }

Al seguir esta estrategia, tu código es robusto, modular y fácil de probar, ya que cada componente cumple su función sin depender de otros.


Necesito que en consecuencia de una acción realizada en un controller, pase otra cosa que no tiene que ver con el objetivo del controller pero que necesita operar sobre el request y la sesión.

Tu pregunta es muy específica y apunta a un dilema común. Si una tarea debe ser desacoplada del controlador pero necesita imperiosamente el objeto Request y la Session, el enfoque con eventos y listeners sigue siendo la solución correcta, pero con una restricción crucial: el listener no puede ser asíncrono.

La clave es que el listener se ejecutará de forma síncrona, es decir, dentro del mismo ciclo de vida de la solicitud HTTP.

Esto te permite:

  • Desacoplar la lógica: Tu controlador no se llena de tareas que no le corresponden. Simplemente "anuncia" que una acción ha ocurrido.

  • Acceder al Request y Session: Como el listener se ejecuta en el mismo proceso de la solicitud, el estado del Request y la Session aún están disponibles y son accesibles.

El enfoque correcto

  1. Crea un Evento: Un simple objeto de datos (POPO - Plain Old PHP Object) que contiene las referencias necesarias, incluyendo el objeto Request.

  2. Dispara el Evento en el Controlador: En el momento adecuado, disparas el evento.

  3. Crea un Listener Síncrono: Este listener recibirá el evento y podrá operar sobre el Request y la Session sin problemas. No lo marques como ShouldQueue.

Este es el único método seguro para mantener el acceso al estado HTTP mientras se mantiene un cierto nivel de desacoplamiento.


Ejemplo de código paso a paso

Vamos a crear un escenario donde, después de que un usuario actualiza su perfil, quieres registrar su actividad en la sesión y guardar su historial de cambios en una base de datos. La segunda tarea no es responsabilidad directa del controlador, pero requiere los datos de la solicitud.

Evento
<?php

namespace App\Events;

use Illuminate\Http\Request;
use Illuminate\Foundation\Events\Dispatchable;

class ProfileUpdated
{
use Dispatchable;

public Request $request;
public $user;

/**
* Crea una nueva instancia del evento.
*
* @param \Illuminate\Http\Request $request
* @return void
*/
public function __construct(Request $request, $user)
{
// En un listener síncrono, es seguro pasar el objeto Request
$this->request = $request;
$this->user = $user;
}
}

Listener
<?php

namespace App\Listeners;

use App\Events\ProfileUpdated;
use Illuminate\Support\Facades\Log;

class LogProfileActivity
{
/**
* Maneja el evento.
*
* @param \App\Events\ProfileUpdated $event
* @return void
*/
public function handle(ProfileUpdated $event)
{
// Accedemos a la data de la solicitud y la sesión a través del evento.
$request = $event->request;
$user = $event->user;

// Tarea 1: Guardar un registro de actividad en la base de datos
// Aquí puedes usar la información del $request, como el IP, user-agent, etc.
Log::info("El usuario {$user->id} actualizó su perfil desde la IP: {$request->ip()}");

// Tarea 2: Agregar un mensaje flash a la sesión del usuario
// Accedes a la sesión directamente, ya que el listener es síncrono.
$request->session()->flash('status', 'Tu perfil ha sido actualizado con éxito.');

}
}

Controlador
<?php

namespace App\Http\Controllers;

use App\Events\ProfileUpdated;
use App\Models\User;
use Illuminate\Http\Request;

class ProfileController extends Controller
{
public function update(Request $request)
{
$user = auth()->user();

// Lógica de validación y actualización del modelo
$user->fill($request->validated())->save();

// Disparamos el evento pasando el objeto Request y el usuario.
ProfileUpdated::dispatch($request, $user);

return redirect()->back();
}
}

Resumen del enfoque

  • Tu controlador llama a una sola línea: ProfileUpdated::dispatch($request, $user);. Su lógica principal se mantiene enfocada en la actualización del perfil.

  • El Evento actúa como un simple contenedor de datos, llevando el Request y el $user al listener.

  • El Listener realiza las tareas secundarias (logging, manipulación de sesión), operando con todos los datos del Request que necesita.

Este patrón te permite mantener el código limpio y organizado. Si en el futuro necesitas agregar otra tarea, simplemente creas otro listener y lo asocias al mismo evento, sin tocar el controlador original.

No hay comentarios: