Laravel & Vue: Real-Time Notifications using Reverb, Redis, and TDD - 03

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:

  1. Order class not found.
  2. 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

  1. Add a Notification as Unread:

    Redis::sadd("notifications:unread:{$user->id}", $notification->id);
    
  2. 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

  1. Addition of the message Field: The message field was added to the $data array to comply with the new requirement of the Notification model.

  2. 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.

  3. 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.

Conclusion

In this chapter, we implemented a complete notification system using Redis and Laravel, following the TDD cycle. We have achieved:

  1. Generating automatic notifications when the status of an order changes.
  2. Storing notifications in Redis to improve performance.
  3. Synchronizing with the database for long-term persistence.
  4. 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. 🚀

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...

Subscribe for Updates

Provide your email to get email notifications about new posts or updates.