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

José Rafael Gutierrez
hace 1 semana

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:
- Primero, creamos un archivo de configuración para Supervisor:
mkdir -p docker
touch docker/supervisord.conf
- 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
- Ahora, modifica tu archivo
docker-compose.yml
para incluir esta configuración en el serviciolaravel.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'"
- Reinicia los contenedores para aplicar estos cambios:
sail down
sail up -d
- 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
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 elOrderObserver
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:
- Creamos eventos personalizados para cambios de estado en órdenes.
- Implementamos listeners que generan y envían notificaciones.
- Utilizamos colas de trabajo para procesar las notificaciones en segundo plano.
- 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í