Laravel & Vue: Real-Time Notifications with Reverb, Redis, and TDD - 04

José Rafael Gutierrez
2 weeks ago

Chapter 4: Sending Notifications in Laravel with Events and Listeners
Objective
In this chapter, we will implement Laravel's event and listener system to trigger notifications automatically when significant events occur, following the TDD (Test-Driven Development) approach. Specifically:
1. Create custom events for order status changes.
2. Implement listeners that generate and send notifications.
3. Use job queues to process notifications without blocking the application.
4. Broadcast events via Reverb to update the interface in real-time.
Introduction
So far, we have used Observers to generate notifications when an order's status changes. However, the Observer approach has limitations when we need to decouple notification logic or want to process notifications in the background.
Laravel's event and listener system provides a more flexible and scalable architecture. Events act as signals indicating that "something has happened," while listeners are responsible for taking action in response to those events. This separation of responsibilities makes the system easier to maintain and expand.
Additionally, we will use Laravel's queue system to process these notifications in the background, improving application performance and providing a better user experience.
TDD Cycle: Implementing Events for Notifications
1. Write Tests for Events and Listeners
First, let's create a test to verify that when an order's status changes, an event is triggered that generates notifications.
sail artisan make:test OrderStatusEventTest
Modify 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. Run the Initial Test (Red)
Run the test to confirm it fails because the OrderStatusChanged
event does not exist:
sail artisan test --filter=OrderStatusEventTest
Expected errors:
-
Class
OrderStatusChanged
not found.
Now that we've identified the expected errors, let's create the event and modify the Observer to trigger it.
3. Create the OrderStatusChanged
Event
Create a new event:
sail artisan make:event OrderStatusChanged
Modify 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. Modify the Observer to Trigger the Event
Now, let's modify the OrderObserver
to trigger the OrderStatusChanged
event instead of directly handling notifications:
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;
// Trigger the event instead of directly handling notifications
event(new OrderStatusChanged($order, $oldStatus, $newStatus));
}
}
}
5. Run the Test Again (Green)
sail artisan test --filter=OrderStatusEventTest
The test should now pass, confirming that the event is being triggered correctly.
TDD Cycle: Implementing Listeners to Process Notifications
1. Write a Test for the Listener
Create a test to verify that the listener correctly processes the event and generates notifications:
sail artisan make:test OrderStatusListenerTest
Modify 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(); // Clear Redis before the test
// When
$listener->handle($event);
// Then
// Verify notifications in the database
$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.",
]);
// Verify notifications in 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. Run the Listener Test (Red)
Run the test:
sail artisan test --filter=OrderStatusListenerTest
Expected errors:
-
Class
CreateOrderStatusNotifications
not found.
3. Create the Listener to Process Notifications
Create a new listener:
sail artisan make:listener CreateOrderStatusNotifications --event=OrderStatusChanged
Modify 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;
// Notification for the user
$userMessage = "El estado del pedido ha cambiado a: {$this->getStatusLabel($status)}.";
$this->createNotification($order->user_id, $status, $userMessage);
// Notifications for admins
$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)
{
// Create notification in the database
Notification::create([
'user_id' => $userId,
'status' => $status,
'message' => $message,
]);
// Store in Redis for quick access
Redis::lpush("notifications:{$userId}", json_encode([
'message' => $message,
'timestamp' => now()->timestamp,
]));
// Add to the list of unread notifications
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. Create and Register the EventServiceProvider
Since EventServiceProvider
no longer comes by default in Laravel, we first need to create it:
sail artisan make:provider EventServiceProvider
Now, modify the newly created file app/Providers/EventServiceProvider.php
to register our 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,
);
}
}
With these changes, Laravel will correctly register the listener for the OrderStatusChanged
event.
5. Run the Listener Test Again (Green)
sail artisan test --filter=OrderStatusListenerTest
The test should now pass, indicating that the listener is correctly processing the event and generating notifications.
Implementing Queues to Process Notifications in the Background
To improve performance, let's configure our system to process notifications in job queues.
1. Environment Configuration for Queues
Ensure your .env
file has the correct configuration to use Redis as the queue driver:
QUEUE_CONNECTION=redis
REDIS_HOST=redis
REDIS_PORT=6379
2. Create a Test to Verify Queue Processing
sail artisan make:test NotificationQueueTest
Modify 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. Run the Queue Test (Red/Green)
sail artisan test --filter=NotificationQueueTest
The test should pass if you've correctly implemented the listener as a class that implements ShouldQueue
. Laravel automatically processes listeners that implement this interface in the background.
4. Configure Supervisor to Process Queues Automatically
To process queues in a development environment that more closely resembles production, let's configure Supervisor within our Docker container. This will prevent us from having to manually run sail artisan queue:work
every time we need to process background tasks:
- First, create a Supervisor configuration file:
mkdir -p docker
touch docker/supervisord.conf
- Add the following content to the
docker/supervisord.conf
file:
[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
- Now, modify your
docker-compose.yml
file to include this configuration in thelaravel.test
service:
# Find the laravel.test service and add or modify these lines
laravel.test:
# ... other existing configurations
volumes:
# ... other existing volumes
- './docker/supervisord.conf:/etc/supervisor/conf.d/laravel.conf'
command: "/bin/bash -c '/usr/bin/supervisord -c /etc/supervisor/conf.d/laravel.conf'"
- Restart the containers to apply these changes:
sail down
sail up -d
- Verify that the queue:work process is running in the background:
sail shell
ps aux | grep queue:work
You should see output similar to this:
sail xxx x.x x.x xxx xxx ? S xx:xx x:xx php /var/www/html/artisan queue:work --queue=default
With this configuration, Supervisor will keep the queue worker running continuously, automatically restarting it if it fails for any reason. This is much closer to how queues would be handled in a production environment and will save you from having to manually start the worker each time you need to process queued jobs.
If you ever need to manually restart the queue (for example, after significant code changes), you can do so with:
sail artisan queue:restart
Integration with the Existing Application
Now that we've implemented events, listeners, and queues, we should update or replace any direct notification logic in the OrderObserver
to use our new event-based system.
1. Run All Tests to Verify Integration
sail artisan test
When running all our tests, we may find that some previous tests now fail due to the architectural changes we've implemented. For example, the OrderStatusObserverTest
from the previous chapter may fail with this message:
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.
This failure is expected and occurs because we've changed the application's architecture: previously, the Observer directly created notifications, but now it only triggers events that are then processed by listeners.
To adapt our previous test to the new architecture, we need to modify it as follows:
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';
});
// Manually execute the listener
$listener = new CreateOrderStatusNotifications();
$event = new OrderStatusChanged($order, 'in_preparation', 'in_transit');
$listener->handle($event);
// Verify the results
$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. Run All Tests Again to Verify Integration
sail artisan test
If all tests pass, it means our system is working correctly and is fully integrated.
Debugging Common Errors
During the development of the notification system with events and listeners, you may encounter some common errors. Here are solutions to frequent issues:
1. Events Are Not Triggered
If events are not being triggered, verify:
- That the
updating
method in theOrderObserver
is correctly implemented. - That the Observer is registered in the
AppServiceProvider
.
2. Listeners Are Not Processing Events
If listeners are not processing events:
- Confirm that the listener is registered in the
EventServiceProvider
. - Verify that the implementation of the
handle
method in the listener is correct.
3. Notifications Do Not Appear in Redis
If notifications are not being stored in Redis:
- Check the Redis connection using Tinker (
Redis::ping()
). - Ensure the implementation of the
createNotification
method in the listener is correct.
4. Queues Are Not Processing Jobs
If queues are not processing jobs:
- Verify that the listener implements the
ShouldQueue
interface. - Check that the Redis configuration in the
.env
file is correct. - Manually run the queue worker:
sail artisan queue:work
.
Conclusion
In this chapter, we've implemented a complete notification system based on events and listeners, following the TDD cycle:
- We created custom events for order status changes.
- We implemented listeners that generate and send notifications.
- We used job queues to process notifications in the background.
- We performed tests to verify everything works correctly.
In the next chapter, we'll explore how to integrate Reverb in the frontend with Vue to receive and display these notifications in real-time. 🚀