Laravel & Vue: Notificaciones en Tiempo Real usando Reverb, Redis y TDD - 04

Capítulo 4: Envío de Notificaciones en Laravel con Eventos y Listeners

Objetivo

En este capítulo, implementaremos el sistema de eventos y listeners de Laravel para disparar notificaciones automáticamente cuando ocurran eventos significativos, siguiendo el enfoque TDD (Test-Driven Development). Específicamente:

1. Crear eventos personalizados para cambios de estado en órdenes.
2. Implementar listeners que generen y envíen notificaciones.
3. Utilizar colas de trabajo para procesar las notificaciones sin bloquear la aplicación.
4. Transmitir eventos a través de Reverb para actualizar la interfaz en tiempo real.

Introducción

Hasta ahora, hemos utilizado Observers para generar notificaciones cuando cambia el estado de una orden. Sin embargo, el enfoque de Observers tiene limitaciones cuando necesitamos desacoplar la lógica de la notificación o queremos procesar las notificaciones en segundo plano.

El sistema de eventos y listeners de Laravel proporciona una arquitectura más flexible y escalable. Los eventos actúan como señales que indican que "algo ha ocurrido", mientras que los listeners son los responsables de realizar acciones en respuesta a esos eventos. Esta separación de responsabilidades facilita el mantenimiento y la expansión del sistema.

Además, utilizaremos el sistema de colas (queues) de Laravel para procesar estas notificaciones en segundo plano, mejorando así el rendimiento de la aplicación y proporcionando una mejor experiencia al usuario.

Ciclo TDD: Implementación de Eventos para Notificaciones

1. Escribir Pruebas para Eventos y Listeners

Primero, vamos a crear una prueba para verificar que cuando se cambia el estado de una orden, se dispara un evento que genera notificaciones.

sail artisan make:test OrderStatusEventTest

Modifica tests/Feature/OrderStatusEventTest.php:

namespace Tests\Feature;

use App\Events\OrderStatusChanged;
use App\Models\Order;
use App\Models\User;
use Event;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Bus;

class OrderStatusEventTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();
        // Fake events and queued jobs
        Event::fake([OrderStatusChanged::class]);
        Bus::fake();
    }

    public function test_order_status_change_fires_event()
    {
        // Given
        $user = User::factory()->create();
        $order = Order::create([
            'user_id' => $user->id,
            'delivery_status' => 'in_preparation',
        ]);

        // When
        $order->update(['delivery_status' => 'in_transit']);

        // Then
        Event::assertDispatched(OrderStatusChanged::class, function ($event) use ($order) {
            return $event->order->id === $order->id &&
                $event->oldStatus === 'in_preparation' &&
                $event->newStatus === 'in_transit';
        });
    }
}

2. Ejecutar la Prueba Inicial (Rojo)

Ejecuta el test para confirmar que falla debido a que no existe el evento OrderStatusChanged:

sail artisan test --filter=OrderStatusEventTest

Errores esperados:

  • Class OrderStatusChanged not found.

Ahora que hemos identificado los errores esperados, vamos a crear el evento y modificar el Observer para dispararlo.

3. Crear el Evento OrderStatusChanged

Crea un nuevo evento:

sail artisan make:event OrderStatusChanged

Modifica app/Events/OrderStatusChanged.php:

namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderStatusChanged implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public $order;
    public $oldStatus;
    public $newStatus;

    public function __construct(Order $order, string $oldStatus, string $newStatus)
    {
        $this->order = $order;
        $this->oldStatus = $oldStatus;
        $this->newStatus = $newStatus;
    }

    public function broadcastOn()
    {
        return [
            new PrivateChannel('user-notifications.' . $this->order->user_id),
            new PrivateChannel('admin-notifications'),
        ];
    }

    public function broadcastAs()
    {
        return 'order.status.changed';
    }

    public function broadcastWith()
    {
        return [
            'order_id' => $this->order->id,
            'old_status' => $this->getStatusLabel($this->oldStatus),
            'new_status' => $this->getStatusLabel($this->newStatus),
            'timestamp' => now()->timestamp,
        ];
    }

    private function getStatusLabel(string $status): string
    {
        return [
            'in_preparation' => 'En preparación',
            'in_transit' => 'En tránsito',
            'delivered' => 'Entregado',
        ][$status] ?? $status;
    }
}

4. Modificar el Observer para Disparar el Evento

Ahora, modifiquemos el OrderObserver para que dispare el evento OrderStatusChanged en lugar de gestionar directamente las notificaciones:

namespace App\Observers;

use App\Events\OrderStatusChanged;
use App\Models\Order;

class OrderObserver
{
    public function updating(Order $order)
    {
        if ($order->isDirty('delivery_status')) {
            $oldStatus = $order->getOriginal('delivery_status');
            $newStatus = $order->delivery_status;

            // Disparar el evento en lugar de gestionar directamente las notificaciones
            event(new OrderStatusChanged($order, $oldStatus, $newStatus));
        }
    }
}

5. Ejecutar la Prueba Nuevamente (Verde)

sail artisan test --filter=OrderStatusEventTest

La prueba ahora debería pasar, ya que estamos disparando el evento correctamente.

Ciclo TDD: Implementación de Listeners para Procesar Notificaciones

1. Escribir Prueba para el Listener

Creamos una prueba para verificar que el listener procese correctamente el evento y genere las notificaciones:

sail artisan make:test OrderStatusListenerTest

Modifica tests/Feature/OrderStatusListenerTest.php:

namespace Tests\Feature;

use App\Events\OrderStatusChanged;
use App\Listeners\CreateOrderStatusNotifications;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;

class OrderStatusListenerTest extends TestCase
{
    use RefreshDatabase;

    public function test_listener_creates_notifications_in_database_and_redis()
    {
        // Given
        Queue::fake();
        $user = User::factory()->create();
        $admin = User::factory()->state(['is_admin' => true])->create();

        $order = Order::create([
            'user_id' => $user->id,
            'delivery_status' => 'in_preparation'
        ]);

        $event = new OrderStatusChanged($order, 'in_preparation', 'in_transit');
        $listener = new CreateOrderStatusNotifications();
        Redis::flushall(); // Limpiamos Redis antes de la prueba

        // When
        $listener->handle($event);

        // Then
        // Verificar notificaciones en la base de datos
        $this->assertDatabaseHas('notifications', [
            'user_id' => $user->id,
            'status' => 'in_transit',
            'message' => 'El estado del pedido ha cambiado a: En tránsito.',
        ]);

        $this->assertDatabaseHas('notifications', [
            'user_id' => $admin->id,
            'status' => 'in_transit',
            'message' => "El pedido #{$order->id} ha cambiado de estado a: En tránsito.",
        ]);

        // Verificar notificaciones en Redis
        $userNotification = json_decode(Redis::lpop("notifications:{$user->id}"), true);
        $adminNotification = json_decode(Redis::lpop("notifications:{$admin->id}"), true);

        $this->assertEquals('El estado del pedido ha cambiado a: En tránsito.', $userNotification['message']);
        $this->assertEquals("El pedido #{$order->id} ha cambiado de estado a: En tránsito.", $adminNotification['message']);
    }
}

2. Ejecutar la Prueba del Listener (Rojo)

Ejecuta el test:

sail artisan test --filter=OrderStatusListenerTest

Errores esperados:

  • Class CreateOrderStatusNotifications not found.

3. Crear el Listener para Procesar Notificaciones

Crea un nuevo listener:

sail artisan make:listener CreateOrderStatusNotifications --event=OrderStatusChanged

Modifica app/Listeners/CreateOrderStatusNotifications.php:

namespace App\Listeners;

use App\Events\OrderStatusChanged;
use App\Models\Notification;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Redis;

class CreateOrderStatusNotifications implements ShouldQueue
{
    use InteractsWithQueue;

    public function handle(OrderStatusChanged $event)
    {
        $order = $event->order;
        $status = $event->newStatus;

        // Notificación para el usuario
        $userMessage = "El estado del pedido ha cambiado a: {$this->getStatusLabel($status)}.";
        $this->createNotification($order->user_id, $status, $userMessage);

        // Notificaciones para administradores
        $adminMessage = "El pedido #{$order->id} ha cambiado de estado a: {$this->getStatusLabel($status)}.";
        $admins = User::where('is_admin', true)->get();

        foreach ($admins as $admin) {
            $this->createNotification($admin->id, $status, $adminMessage);
        }
    }

    private function createNotification($userId, $status, $message)
    {
        // Crear notificación en la base de datos
        Notification::create([
            'user_id' => $userId,
            'status' => $status,
            'message' => $message,
        ]);

        // Almacenar en Redis para acceso rápido
        Redis::lpush("notifications:{$userId}", json_encode([
            'message' => $message,
            'timestamp' => now()->timestamp,
        ]));

        // Añadir a la lista de notificaciones no leídas
        Redis::sadd("notifications:unread:{$userId}", $userId . '-' . now()->timestamp);
    }

    private function getStatusLabel(string $status): string
    {
        return [
            'in_preparation' => 'En preparación',
            'in_transit' => 'En tránsito',
            'delivered' => 'Entregado',
        ][$status] ?? $status;
    }
}

4. Crear y Registrar el EventServiceProvider

Dado que EventServiceProvider ya no viene de manera predeterminada en Laravel, primero necesitamos crearlo:

sail artisan make:provider EventServiceProvider

Ahora, modifiquemos el archivo recién creado app/Providers/EventServiceProvider.php para registrar nuestro listener:

namespace App\Providers;

use App\Events\OrderStatusChanged;
use App\Listeners\CreateOrderStatusNotifications;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;

class EventServiceProvider extends ServiceProvider
{
    /**
     * Register any events for your application.
     */
    public function boot(): void
    {
        Event::listen(
            OrderStatusChanged::class,
            CreateOrderStatusNotifications::class,
        );
    }
}

Con estos cambios, Laravel registrará correctamente el listener para el evento OrderStatusChanged.

5. Ejecutar la Prueba del Listener Nuevamente (Verde)

sail artisan test --filter=OrderStatusListenerTest

La prueba ahora debería pasar, indicando que el listener está procesando correctamente el evento y generando las notificaciones.

Implementación de Colas para Procesar Notificaciones en Segundo Plano

Para mejorar el rendimiento, vamos a configurar nuestro sistema para procesar las notificaciones en colas de trabajo.

1. Configuración del Entorno para Colas

Asegúrate de que tu archivo .env tenga la configuración adecuada para utilizar Redis como driver de colas:

QUEUE_CONNECTION=redis
REDIS_HOST=redis
REDIS_PORT=6379

2. Crear un Test para Verificar el Procesamiento en Cola

sail artisan make:test NotificationQueueTest

Modifica tests/Feature/NotificationQueueTest.php:

namespace Tests\Feature;

use App\Events\OrderStatusChanged;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;

class NotificationQueueTest extends TestCase
{
    use RefreshDatabase;

    public function test_order_status_notifications_are_queued()
    {
        // Given
        Event::fake();
        $user = User::factory()->create();
        $order = Order::create([
            'user_id' => $user->id,
            'delivery_status' => 'in_preparation',
        ]);

        // When
        event(new OrderStatusChanged($order, 'in_preparation', 'in_transit'));

        // Then
        Event::assertDispatched(OrderStatusChanged::class, function ($event) use ($order) {
            return $event->order->id === $order->id &&
                $event->oldStatus === 'in_preparation' &&
                $event->newStatus === 'in_transit';
        });
    }
}

3. Ejecutar la Prueba de Cola (Rojo/Verde)

sail artisan test --filter=NotificationQueueTest

La prueba debería pasar si has implementado correctamente el listener como una clase que implementa ShouldQueue. Laravel automáticamente procesa los listeners que implementan esta interfaz en segundo plano.

4. Configurar Supervisor para Procesar las Colas Automáticamente

Para procesar las colas en un entorno de desarrollo que se asemeje más a producción, vamos a configurar Supervisor dentro de nuestro contenedor Docker. Esto nos evitará tener que ejecutar manualmente sail artisan queue:work cada vez que necesitemos procesar tareas en segundo plano:

  1. Primero, creamos un archivo de configuración para Supervisor:
mkdir -p docker
touch docker/supervisord.conf
  1. Añade el siguiente contenido al archivo docker/supervisord.conf:
[supervisord]
nodaemon=true
user=sail
logfile=/var/www/html/storage/logs/supervisord.log
pidfile=/var/run/supervisord.pid

[program:queue-work]
command=php /var/www/html/artisan queue:work --queue=default
directory=/var/www/html
user=sail
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/queue-work.log
  1. Ahora, modifica tu archivo docker-compose.yml para incluir esta configuración en el servicio laravel.test:
# Busca el servicio laravel.test y añade o modifica estas líneas
laravel.test:
    # ... otras configuraciones existentes
    volumes:
        # ... otros volumes existentes
        - './docker/supervisord.conf:/etc/supervisor/conf.d/laravel.conf'
    command: "/bin/bash -c '/usr/bin/supervisord -c /etc/supervisor/conf.d/laravel.conf'"
  1. Reinicia los contenedores para aplicar estos cambios:
sail down
sail up -d
  1. Verifica que el proceso de queue:work esté ejecutándose en segundo plano:
sail shell
ps aux | grep queue:work

Deberías ver una salida similar a esta:

sail      xxx  x.x  x.x xxx xxx ?        S    xx:xx   x:xx php /var/www/html/artisan queue:work --queue=default

Con esta configuración, Supervisor mantendrá el worker de colas ejecutándose constantemente, reiniciándolo automáticamente si falla por alguna razón. Esto es mucho más cercano a cómo se manejarían las colas en un entorno de producción y te evitará tener que iniciar manualmente el worker cada vez que necesites procesar trabajos en cola.

Si en algún momento necesitas reiniciar manualmente la cola (por ejemplo, después de cambios importantes en el código), puedes hacerlo con:

sail artisan queue:restart

Integración con la Aplicación Existente

Ahora que hemos implementado eventos, listeners y colas, deberíamos actualizar o reemplazar cualquier lógica de notificación directa en el OrderObserver para utilizar nuestro nuevo sistema basado en eventos.

1. Ejecutar Todos los Tests para Verificar la Integración

sail artisan test

Al ejecutar todos nuestros tests, es posible que encontremos que algunos tests anteriores ahora fallan debido a los cambios arquitectónicos que hemos implementado. Por ejemplo, el test OrderStatusObserverTest del capítulo anterior puede fallar con este mensaje:

 FAILED  Tests\Feature\OrderStatusObserverTest > it creates notifications for user and admins on order status change
  Failed asserting that a row in the table [notifications] matches the attributes {
    "user_id": 8,
    "message": "El estado del pedido ha cambiado a: En tránsito."
}.

The table is empty.

Este fallo es esperado y ocurre porque hemos cambiado la arquitectura de nuestra aplicación: antes, el Observer creaba directamente las notificaciones, pero ahora sólo dispara eventos que luego son procesados por listeners.

Para adaptar nuestro test anterior a la nueva arquitectura, debemos modificarlo de la siguiente manera:

namespace Tests\Feature;

use App\Events\OrderStatusChanged;
use App\Listeners\CreateOrderStatusNotifications;
use App\Models\Order;
use App\Models\User;
use App\Observers\OrderObserver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Redis;
use Tests\TestCase;

class OrderStatusObserverTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_creates_notifications_for_user_and_admins_on_order_status_change()
    {
        // Given
        Event::fake([OrderStatusChanged::class]);

        $user = User::factory()->create();
        $admin = User::factory()->state(['is_admin' => true])->create();

        Order::observe(OrderObserver::class);

        // When
        $order = Order::create([
            'user_id' => $user->id,
            'delivery_status' => 'in_preparation',
        ]);

        $order->update(['delivery_status' => 'in_transit']);

        // Then
        Event::assertDispatched(OrderStatusChanged::class, function ($event) use ($order) {
            return $event->order->id === $order->id &&
                $event->oldStatus === 'in_preparation' &&
                $event->newStatus === 'in_transit';
        });

        // Ejecutamos el listener manualmente
        $listener = new CreateOrderStatusNotifications();
        $event = new OrderStatusChanged($order, 'in_preparation', 'in_transit');
        $listener->handle($event);

        // Verificamos los resultados
        $this->assertDatabaseHas('notifications', [
            'user_id' => $user->id,
            'message' => 'El estado del pedido ha cambiado a: En tránsito.',
        ]);

        $this->assertDatabaseHas('notifications', [
            'user_id' => $admin->id,
            'message' => "El pedido #{$order->id} ha cambiado de estado a: En tránsito.",
        ]);

        $userNotification = json_decode(Redis::lpop("notifications:{$user->id}"), true);
        $adminNotification = json_decode(Redis::lpop("notifications:{$admin->id}"), true);

        $this->assertEquals('El estado del pedido ha cambiado a: En tránsito.', $userNotification['message']);
        $this->assertEquals("El pedido #{$order->id} ha cambiado de estado a: En tránsito.", $adminNotification['message']);
    }
}

2. Ejecutar Nuevamente Todos los Tests para Verificar la Integración

sail artisan test

test

Si todos los tests pasan, significa que nuestro sistema está funcionando correctamente y de manera integrada.

Depuración de Errores Comunes

Durante el desarrollo del sistema de notificaciones con eventos y listeners, es posible que encuentres algunos errores comunes. Aquí hay algunas soluciones a problemas frecuentes:

1. Los Eventos No Se Disparan

Si los eventos no se están disparando, verifica:

  • Que el método updating en el OrderObserver esté correctamente implementado.
  • Que el Observer esté registrado en el AppServiceProvider.

2. Los Listeners No Procesan los Eventos

Si los listeners no están procesando los eventos:

  • Confirma que el listener esté registrado en el EventServiceProvider.
  • Verifica que la implementación del método handle en el listener sea correcta.

3. Las Notificaciones No Aparecen en Redis

Si las notificaciones no se almacenan en Redis:

  • Comprueba la conexión con Redis usando Tinker (Redis::ping()).
  • Asegúrate de que la implementación del método createNotification en el listener sea correcta.

4. Las Colas No Procesan los Jobs

Si las colas no están procesando los jobs:

  • Verifica que el listener implemente la interfaz ShouldQueue.
  • Comprueba que la configuración de Redis en el archivo .env sea correcta.
  • Ejecuta manualmente el worker de colas: sail artisan queue:work.

Conclusión

En este capítulo, hemos implementado un sistema completo de notificaciones basado en eventos y listeners, siguiendo el ciclo TDD:

  1. Creamos eventos personalizados para cambios de estado en órdenes.
  2. Implementamos listeners que generan y envían notificaciones.
  3. Utilizamos colas de trabajo para procesar las notificaciones en segundo plano.
  4. Realizamos pruebas para verificar que todo funcione correctamente.

En el próximo capítulo, exploraremos cómo integrar Reverb en el frontend con Vue para recibir y mostrar estas notificaciones en tiempo real. 🚀

En esta serie
Capítulo 1: Introducción y Configuración de Reverb y Redis en el Entorno Laravel
Capítulo 2: Creación de Canales de Notificaciones con Reverb en Laravel
Capítulo 3: Configuración de Redis para Almacenar y Administrar Notificaciones
Capítulo 4: Envío de Notificaciones en Laravel con Eventos y Listeners 👈🏽 Estás aquí

José Rafael Gutierrez

Soy un desarrollador web con más de 14 años de experiencia, especializado en la creación de sistemas a medida. Apasionado por la tecnología, la ciencia, y la lectura, disfruto resolviendo problemas de...

Suscríbete para Actualizaciones

Proporcione su correo electrónico para recibir notificaciones sobre nuevas publicaciones o actualizaciones.