John Alcher
Posted on April 27, 2019
Cover by freestocks from Pexels
Contents
- Contents
- Introduction
- Our Problem At Hand
- The Obvious Symptom
- Solution
- Don't Forget The Tests!
- How about Regular Eloquent Models?
- Conclusion
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
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'
);
}
}
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());
// ...
}
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
);
}
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
);
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.
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....'
];
}
}
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....'
];
}
}
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....'
];
}
}
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.
Posted on April 27, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.