Laravel: Queued Notifications for a Deleted User or Eloquent Model

alchermd

John Alcher

Posted on April 27, 2019

Laravel: Queued Notifications for a Deleted User or Eloquent Model

Cover by freestocks from Pexels

Contents

Introduction

Out of the box, Laravel provides strong facilities for utilizing queues and/or Redis to support your queued jobs, mails, or any other expensive (time or resource-wise) operations. But the asynchronous nature of queues might bite you in your back side when dealing with objects wherein their current state is tricky to visualize once the queued action is executing. In this article, we'll take a look at two instances where this may be the case.

Our Problem at Hand

Our friends at ACME Airconditioning has a shopping cart system wherein customers can buy their desired AC units online, provided they have an account in the system. Now, a feature request had come in where the specs is described (in GWT) as follows:



Feature: Farewell Email Promotion for a Deleted Customer
    Scenario: Customer deletes their account
        Given I have my email verified

        When I delete my customer account

        Then I will receive an email containing ACME's farewell promo


Enter fullscreen mode Exit fullscreen mode

Now how do we implement this feature? It seems straightforward: send the user an email before deleting their account:



// CustomerController.php

public function destroy(Customer $customer)
{
    $customer->notify(new FarewellPromotion());
    $customer->delete();

    // ...
}

// FarewellPromotion.php

class FarewellPromotion extends Notification implements ShouldQueue
{
    use Queueable;

    public function toMail($notifiable)
    {
        return (new MailMessage)
            ->line('We are sad to see you go.')
            ->line('How about a 2-for-1 special?')
            ->action(
                'Buy Now,
                '/farewell-promo'
            );
    }
}


Enter fullscreen mode Exit fullscreen mode

But to our surprise, $customer never receives the email! Why? Let's examine it further.

The Obvious Symptom

Hmmm, take a second look at the program flow. It should become apparent when you consider that $customer->notify(new FarewellPromotion()); pushes the notification into a queue, thus notifying the user asynchronously. In a nutshell, the account deletion happens before the notification is sent. This causes the notification to fail because the recipient that it is intended for is already deleted (or more precisely, null).

Solution

You can solve this by not queuing the notification (unacceptable for UX) or by storing the customer's email address and sending the mail directly (slightly better, but discards the abstraction of the notifications). But luckily, Laravel supports On-Demand Notifications that allows notification to be sent without a Notifiable instance (in our case, the customer).



// CustomerController.php

public function destroy(Customer $customer)
{
    $customer->delete();

    // Doesn't matter if it's before or after deletion since 
    // the Customer instance still has its properties 
    // up until the request context expires.
     Notification::route('mail', $customer->email)
            ->notify(new FarewellPromotion());

    // ...
}


Enter fullscreen mode Exit fullscreen mode

Don't Forget The Tests!

Once you changed the implementation from a simple notification into an on-demand one, you'd have to change any test cases that account for this feature. A testcase might look something like this:



/** @test */
public function customers_that_deletes_their_account_is_notified_of_the_farewell_promo()
{
    Notification::fake();

    $customer = factory(Customer::class)->create();

    $this->delete('/customers/' . $customer->id);

    Notification::assertSentTo(
        $customer,
        FarewellPromotion::class
    );
}


Enter fullscreen mode Exit fullscreen mode

This testcase would now fail because $customer is already null once the assertion is made. Instead, we should use the AnonymousNotifiable class to proxy for our customer, as is the case when using Notification::route



use Illuminate\Notifications\AnonymousNotifiable;

Notification::assertSentTo(
    new AnonymousNotifiable,
    FarewellPromotion::class
);


Enter fullscreen mode Exit fullscreen mode

How about Regular Eloquent Models?

That covers how to notify a deleted Customer model that uses the Notifiable trait. But what if we need a deleted model for a queued notification? Consider the following:



Feature: Recommend another Product when a Customer's "liked" Product is Deleted
    Scenario: A Product is Deleted
        Given I have my email verified
            And I have "liked" a product

        When that product is deleted

        Then I will receive a notification that recommends me another product.


Enter fullscreen mode Exit fullscreen mode

Now obviously, blindly passing the Product model into a queued notification will result with the same async problem that we encountered before:



// ProductsController.php

public function delete(Product $product)
{
    $product->delete();

    $product->likers->notify(new SimilarProductRecommendation($product));
}

// SimilarProductRecommendation.php
class SimilarProductRecommendation extends Notification implements ShouldQueue
{
    use Queueable;

    private $product;

    public function __construct(Product $product)
    {
        $this->product = $product;
    }

    public function toArray($notifiable)
    {
        return [
            'message' => $this->product->name . ' has been deleted. Try this other product....' 
        ];
    }
}


Enter fullscreen mode Exit fullscreen mode

There is no clear cut solution about it, because unlike Customer, Product is not Notifiable and we won't be able to leverage the Notification::route() method. When I'm faced with this problem, I use one of the following solutions:

1. Pass an array representation of the model



// ProductsController.php
public function delete()
{    
    $product->delete();

    $product->likers->notify(new SimilarProductRecommendation($product->toArray()));
}

// SimilarProductRecommendation.php
class SimilarProductRecommendation extends Notification implements ShouldQueue
{
    use Queueable;

    private $product;

    public function __construct(array $product)
    {
        $this->product = $product;
    }

    public function toArray($notifiable)
    {
        return [
            'message' => $this->product['name'] . ' has been deleted. Try this other product....' 
        ];
    }
}


Enter fullscreen mode Exit fullscreen mode

2. Pass only the relevant model fields



// ProductsController.php
public function delete()
{    
    $product->delete();

    $product->likers->notify(new SimilarProductRecommendation($product->name));
}

// SimilarProductRecommendation.php
class SimilarProductRecommendation extends Notification implements ShouldQueue
{
    use Queueable;

    private $productName;

    public function __construct(string $productName)
    {
        $this->productName = $productName;
    }

    public function toArray($notifiable)
    {
        return [
            'message' => $this->productName . ' has been deleted. Try this other product....' 
        ];
    }
}


Enter fullscreen mode Exit fullscreen mode

Both of these solutions bypass the unpredictable state of the deleted model by passing in separate values instead of a reference to the model itself.

Conclusion

In this article, we've covered the trickiness that queued notifications might present when handling deleted entities. We've also covered how to notify a deleted User (or any model that implements the Notifiable trait, to be precise) and testing the said action. Lastly, we took a look at passing deleted Eloquent model references to a queued notifications, and discussed a couple of solutions on how to "access" these deleted models.

💖 💪 🙅 🚩
alchermd
John Alcher

Posted on April 27, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related