Laravel & Vue: Real-Time Notifications using Reverb, Redis, and TDD - 03
José Rafael Gutierrez
1 month ago
Chapter 3: Configuring Redis to Store and Manage Notifications
Objective
In this chapter, we will configure Redis to manage real-time notifications, synchronizing them with the database for long-term persistence. We will use the TDD cycle (red → green → refactor) to develop an efficient real-time notification system with Redis and robust persistence in the database.
1. Generate automatic notifications when the status of an order (Order
) changes.
2. Store notifications in Redis with expiration times to improve performance.
3. Notify both the user and administrators about changes in the status of an order.
4. Implement a system to mark notifications as "read" or "unread" using Redis.
Introduction
In modern applications, Redis is an in-memory database that offers exceptional speed for read and write operations. This makes it an ideal solution for handling real-time notifications, where latency must be minimal. By combining Redis with a relational database like MySQL or PostgreSQL, we can get the best of both worlds: speed in delivering notifications and persistence to maintain a permanent history.
We will work with a new entity called Order
, which represents an order with a delivery status (delivery_status
). Every time the status of an order changes (for example, from "in preparation" to "in transit"), an Observer will be responsible for automatically generating notifications for both the user and the system administrators.
TDD Cycle: Generating Notifications
1. Write the Initial Test
We will start by writing a test to validate that notifications are generated when the status of an order changes and that these notifications are sent to both the user and the administrators.
Run the following command to create a test file:
sail artisan make:test OrderStatusObserverTest
Modify tests/Feature/OrderStatusObserverTest.php
with the following content:
namespace Tests\Feature;
use App\Models\Notification;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
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
$user = User::factory()->create();
$admin = User::factory()->state(['is_admin' => true])->create();
// When
$order = Order::create([
'user_id' => $user->id,
'delivery_status' => 'in_preparation',
]);
$order->update(['delivery_status' => 'in_transit']);
// Then
$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 #1 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 #1 ha cambiado de estado a: En tránsito.', $adminNotification['message']);
}
}
2. Run the Initial Test (Red)
Run the test to confirm that it fails because neither the Order
model nor its observer exist:
sail artisan test --filter=OrderStatusObserverTest
Expected Errors:
-
Order
class not found. -
Migration for
orders
not defined.
Now that we have identified the expected errors, let's create the Order
model and its respective migration to solve the first failure.
Creation of Entities and Migrations
3. Create the Model and Migration for Order
Create the Order
model and its migration with the following command:
sail artisan make:model Order -m
Modify the migration generated in database/migrations/xxxx_xx_xx_create_orders_table.php
to include the table structure:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('delivery_status');
$table->timestamps();
});
In the app/Models/Order.php
model, add the fillable
attribute:
protected $fillable = ['user_id', 'delivery_status'];
Run the migration:
sail artisan migrate
4. Create a New Migration to Add the message
Field to notifications
To add the message
field to the notifications
table, run:
sail artisan make:migration add_message_to_notifications_table --table=notifications
Modify the migration generated in database/migrations/xxxx_xx_xx_add_message_to_notifications_table.php
:
Schema::table('notifications', function (Blueprint $table) {
$table->text('message');
});
Run the migration:
sail artisan migrate
Modify the Notification
model to include message
in $fillable
:
protected $fillable = ['user_id', 'status', 'message'];
Managing the Read Status of Notifications
To enhance the user experience, we will implement a functionality that allows marking notifications as "read" or "unread" using Redis.
Explanation of the Process:
-
When a notification is created, it is automatically added to the set of unread notifications in Redis.
-
When the user reviews a notification, it is removed from the unread set.
Code to Handle the Read Status
-
Add a Notification as Unread:
Redis::sadd("notifications:unread:{$user->id}", $notification->id);
-
Mark a Notification as Read:
Redis::srem("notifications:unread:{$user->id}", $notificationId);
5. Implement the Observer to Generate Notifications (Green)
Create an Observer for the Order
model:
sail artisan make:observer OrderObserver --model=Order
Modify app/Observers/OrderObserver.php
with the logic to generate the notifications:
namespace App\Observers;
use App\Models\Notification;
use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\Redis;
class OrderObserver
{
public function updating(Order $order)
{
if ($order->isDirty('delivery_status')) {
$status = $order->delivery_status;
// Message for the user
$userMessage = "El estado del pedido ha cambiado a: {$this->getStatusLabel($status)}.";
Notification::create([
'user_id' => $order->user_id,
'status' => $status,
'message' => $userMessage,
]);
Redis::lpush("notifications:{$order->user_id}", json_encode([
'message' => $userMessage,
'timestamp' => now()->timestamp,
]));
// Message for administrators
$adminMessage = "El pedido #{$order->id} ha cambiado de estado a: {$this->getStatusLabel($status)}.";
$admins = User::where('is_admin', true)->get();
foreach ($admins as $admin) {
Notification::create([
'user_id' => $admin->id,
'status' => $status,
'message' => $adminMessage,
]);
Redis::lpush("notifications:{$admin->id}", json_encode([
'message' => $adminMessage,
'timestamp' => now()->timestamp,
]));
}
}
}
private function getStatusLabel(string $status): string
{
return [
'in_preparation' => 'En preparación',
'in_transit' => 'En tránsito',
'delivered' => 'Entregado',
][$status] ?? $status;
}
}
Register the observer in AppServiceProvider
:
use App\Models\Order;
use App\Observers\OrderObserver;
public function boot()
{
Order::observe(OrderObserver::class);
}
Now, run the test again:
sail artisan test --filter=OrderStatusObserverTest
PASS Tests\Feature\OrderStatusObserverTest
✓ it creates notifications for user and admins on order status change
Updating the NotificationTest
Test
Why Update the Test?
After adding the message
field to the notifications
table, our original test started failing because it did not consider this new required field. Let's update the test to reflect this new data structure and comply with the TDD cycle.
1. Running the Test (Red)
If we run the following command to run all tests:
sail artisan test
We get the following error:
FAIL Tests\Feature\NotificationTest
⨯ it creates a notification
Failed asserting that a row in the table [notifications] matches the attributes {
"user_id": 1,
"status": "Enviado"
}.
Found similar results: [
{
"user_id": 1,
"status": "Enviado",
"message": null
}
].
Error Analysis:
The test fails because creating a notification now requires a non-null message
. The migration we added sets the message
field as a text
, so we must include it when creating a notification in the test.
2. Modifying the Test (Green)
To solve the error, we will update the NotificationTest
test to include the message
field when creating a notification.
Modify the tests/Feature/NotificationTest.php
file as follows:
<?php
namespace Tests\Feature;
use App\Models\Notification;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class NotificationTest extends TestCase
{
use RefreshDatabase;
public function test_it_creates_a_notification()
{
// Given
$user = User::factory()->create();
$data = [
'user_id' => $user->id,
'status' => 'Enviado',
'message' => 'El pedido ha sido enviado',
];
// When
Notification::create($data);
// Then
$this->assertDatabaseHas('notifications', $data);
}
}
3. Running the Corrected Test
Now, run the test again:
sail artisan test --filter=NotificationTest
The test should pass correctly:
PASS Tests\Feature\NotificationTest
✓ it creates a notification
4. Now we run all the tests
sail artisan test
Explanation of the Changes
-
Addition of the
message
Field: Themessage
field was added to the$data
array to comply with the new requirement of theNotification
model. -
Consistency with the Migration: The
message
field is mandatory due to the migration we added. This update to the test ensures that we are creating valid notifications that meet the current requirements of the model and the database. -
Application of the TDD Cycle:
-
Red: The test failed after adding the
message
field. -
Green: We updated the test to include the
message
field, and the test passed. - Refactor: No additional refactoring is required in this case, as the code is clear and concise.
-
Red: The test failed after adding the
Conclusion
In this chapter, we implemented a complete notification system using Redis and Laravel, following the TDD cycle. We have achieved:
- Generating automatic notifications when the status of an order changes.
- Storing notifications in Redis to improve performance.
- Synchronizing with the database for long-term persistence.
- Managing the "read" or "unread" status with Redis.
In the next chapter, we will connect Laravel with the frontend to receive real-time notifications using Laravel Echo. 🚀