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

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:

  1. First, create a Supervisor configuration file:
mkdir -p docker
touch docker/supervisord.conf
  1. 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
  1. Now, modify your docker-compose.yml file to include this configuration in the laravel.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'"
  1. Restart the containers to apply these changes:
sail down
sail up -d
  1. 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

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 the OrderObserver 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:

  1. We created custom events for order status changes.
  2. We implemented listeners that generate and send notifications.
  3. We used job queues to process notifications in the background.
  4. 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. 🚀

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.