Laravel 5.3 - Single Notification for User Collection (followers)
Asked Answered
H

2

24

When I have a single notifiable user, a single entry in the notifications table is inserted, along with a mail/sms sent which is perfectly working via channels.

The issue is when I have a user collection, a list of 1k users following me, and I post an update. Here is what happens when using the Notifiable trait as suggested for multi-user case:

  1. 1k mails/sms sent (issue is not here)
  2. 1k notification entries added to the DB's notifications table

It seems that adding 1k notifications to the DB's notifications table is not an optimal solution. Since the toArray data is the same, and everything else in the DB's notifications table is the same for 1k rows, with the only difference being the notifiable_id of the user notifiable_type.

An optimal solution out of the box would be:

  1. Laravel would pick up the fact that it's an array notifiable_type
  2. Save a single notification as notifiable_type user_array or user with notifiable_id 0 (zero would only be used to signify it's a multi notifiable user)
  3. Create/Use another table notifications_read using the notification_id it just created as the foreign_key and insert 1k rows, of just these fields:

    notification_id notifiable_id notifiable_type read_at

I am hoping there is already a way to do this as I am at this point in my application and would love to use the built in Notifications and channels for this situation, as I am firing off emails/sms notifications, which is fine to repeat 1k times I think, but it's the entry of the same data into the database that is the problem that needs to be optimized.

Any thoughts/ideas how to proceed in this situation?

Howie answered 22/12, 2016 at 1:24 Comment(0)
H
14

Updated 2017-01-14: implemented more correct approach

Quick example:

use Illuminate\Support\Facades\Notification;
use App\Notifications\SomethingCoolHappen;

Route::get('/step1', function () {
    // example - my followers
    $followers = App\User::all();

    // notify them
    Notification::send($followers, new SomethingCoolHappen(['arg1' => 1, 'arg2' => 2]));
});

Route::get('/step2', function () {
    // my follower
    $user = App\User::find(10);

    // check unread subnotifications
    foreach ($user->unreadSubnotifications as $subnotification) {
        var_dump($subnotification->notification->data);
        $subnotification->markAsRead();
    }
});

How to make it work?

Step 1 - migration - create table (subnotifications)

use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;

class CreateSubnotificationsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('subnotifications', function (Blueprint $table) {
            // primary key
            $table->increments('id')->primary();

            // notifications.id
            $table->uuid('notification_id');

            // notifiable_id and notifiable_type
            $table->morphs('notifiable');

            // follower - read_at
            $table->timestamp('read_at')->nullable();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('subnotifications');
    }
}

Step 2 - let's create a model for new subnotifications table

<?php
// App\Notifications\Subnotification.php
namespace App\Notifications;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\DatabaseNotificationCollection;

class Subnotification extends Model
{
    // we don't use created_at/updated_at
    public $timestamps = false;

    // nothing guarded - mass assigment allowed
    protected $guarded = [];

    // cast read_at as datetime
    protected $casts = [
        'read_at' => 'datetime',
    ];

    // set up relation to the parent notification
    public function notification()
    {
        return $this->belongsTo(DatabaseNotification::class);
    }

    /**
     * Get the notifiable entity that the notification belongs to.
     */
    public function notifiable()
    {
        return $this->morphTo();
    }

    /**
     * Mark the subnotification as read.
     *
     * @return void
     */
    public function markAsRead()
    {
        if (is_null($this->read_at)) {
            $this->forceFill(['read_at' => $this->freshTimestamp()])->save();
        }
    }
}

Step 3 - create a custom database notification channel
Updated: using static variable $map to keep first notification id and insert next notifications (with the same data) without creating a record in notifications table

<?php
// App\Channels\SubnotificationsChannel.php
namespace App\Channels;

use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\Notification;

class SubnotificationsChannel
{
    /**
     * Send the given notification.
     *
     * @param  mixed                                  $notifiable
     * @param  \Illuminate\Notifications\Notification $notification
     *
     * @return void
     */
    public function send($notifiable, Notification $notification)
    {
        static $map = [];

        $notificationId = $notification->id;

        // get notification data
        $data = $this->getData($notifiable, $notification);

        // calculate hash
        $hash = md5(json_encode($data));

        // if hash is not in map - create parent notification record
        if (!isset($map[$hash])) {
            // create original notification record with empty notifiable_id
            DatabaseNotification::create([
                'id'              => $notificationId,
                'type'            => get_class($notification),
                'notifiable_id'   => 0,
                'notifiable_type' => get_class($notifiable),
                'data'            => $data,
                'read_at'         => null,
            ]);

            $map[$hash] = $notificationId;
        } else {
            // otherwise use another/first notification id
            $notificationId = $map[$hash];
        }

        // create subnotification
        $notifiable->subnotifications()->create([
            'notification_id' => $notificationId,
            'read_at'         => null
        ]);
    }

    /**
     * Prepares data
     *
     * @param mixed                                  $notifiable
     * @param \Illuminate\Notifications\Notification $notification
     *
     * @return mixed
     */
    public function getData($notifiable, Notification $notification)
    {
        return $notification->toArray($notifiable);
    }
}

Step 4 - create a notification
Updated: now notification supports all channels, not only subnotifications

<?php
// App\Notifications\SomethingCoolHappen.php
namespace App\Notifications;

use App\Channels\SubnotificationsChannel;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;

class SomethingCoolHappen extends Notification
{
    use Queueable;

    protected $data;

    /**
     * Create a new notification instance.
     *
     * @return void
     */
    public function __construct($data)
    {
        $this->data = $data;
    }

    /**
     * Get the notification's delivery channels.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function via($notifiable)
    {
        /**
         * THIS IS A GOOD PLACE FOR DETERMINING NECESSARY CHANNELS
         */
        $via = [];
        $via[] = SubnotificationsChannel::class;
        //$via[] = 'mail';
        return $via;
    }

    /**
     * Get the mail representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return \Illuminate\Notifications\Messages\MailMessage
     */
    public function toMail($notifiable)
    {
        return (new MailMessage)
                    ->line('The introduction to the notification.')
                    ->action('Notification Action', 'https://laravel.com')
                    ->line('Thank you for using our application!');
    }

    /**
     * Get the array representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return array
     */
    public function toArray($notifiable)
    {
        return $this->data;
    }
}

Step 5 - helper trait for "followers"

<?php
// App\Notifications\HasSubnotifications.php
namespace App\Notifications;

trait HasSubnotifications
{
    /**
     * Get the entity's notifications.
     */
    public function Subnotifications()
    {
        return $this->morphMany(Subnotification::class, 'notifiable')
            ->orderBy('id', 'desc');
    }

    /**
     * Get the entity's read notifications.
     */
    public function readSubnotifications()
    {
        return $this->Subnotifications()
            ->whereNotNull('read_at');
    }

    /**
     * Get the entity's unread notifications.
     */
    public function unreadSubnotifications()
    {
        return $this->Subnotifications()
            ->whereNull('read_at');
    }
}

Step 6 - update your Users model
Updated: no required followers method

namespace App;

use App\Notifications\HasSubnotifications;
use Illuminate\Notifications\Notifiable;
use Illuminate\Foundation\Auth\User as Authenticatable;

class User extends Authenticatable
{
    use Notifiable;

    /**
     * Adding helpers to followers:
     *
     * $user->subnotifications - all subnotifications
     * $user->unreadSubnotifications - all unread subnotifications
     * $user->readSubnotifications - all read subnotifications
     */
    use HasSubnotifications;

    /**
     * The attributes that are mass assignable.
     *
     * @var array
     */
    protected $fillable = [
        'name', 'email', 'password',
    ];

    /**
     * The attributes that should be hidden for arrays.
     *
     * @var array
     */
    protected $hidden = [
        'password', 'remember_token',
    ];
}
Hootchykootchy answered 12/1, 2017 at 20:59 Comment(8)
Thank you! This works really well :) In step 4, I noticed the toMail is missing, I have some logic to find followers who want email, but then where do I put that logic? Since the example uses $user->notify(new FollowersUpdate(... it emails the $user when I put toMail in step 4, which is not what we want, as we want to email the followers, using some of the user details. I don't think putting it in FollowerChannel is correct since it handles the db tables entry? Any idea what's the correct location to implement?Howie
@Wonka, As you did it previously. This approach only implements a custom database channel. You can try put your logic in FollowerChannel, but you're right - it is not a good place. In other words you have to use both Notifications: the original notification with sms/email and FollowersUpdate (to make subnotifications). I believe this is small enough price for your optimization.Hootchykootchy
@Wonka, i thought a bit more and came with more correct solution. Check it. Hope you'll find it more useful.Hootchykootchy
Thank you :) I used your updated example which allows multiple channels in a single notification, so for 100 followers, it adds 100 subnotifications but it also sends 100 emails, even if only 80 of the 100 want email, the other 20 get the email too. I opened this question: #41644439 But the offered solution is a foreach for each $follower to figure out their preference and loops over the notification, as opposed to sending the $followers and handling the prefs in the channels.Howie
Is it possible to have the same single notification file with the passed $followers like in your example but also have the follower's mail preferences respected? I tried but wasn't able to get it to work...Howie
@Wonka, yes, put required logic to Step4(Notification) via method. $notifiable is an eloquent model of your follower. Send email only to gmail users: if (stripos($notifiable->email, '@gmail.com') !== false) $via[] = 'mail';Hootchykootchy
When we are using this method is it possible for us to send a single email with all the specified users email in to filed of email. So if we need to inform 10 users with exact same information, instead of triggering 10 different emails, I need to send one email with 10 email ids in to, cc or bcc field. Problem is when we send 10 emails one after other my transnational email provider return error "To many emails per second".Lead
@Lead You may just need a mail queue with throttling.Hootchykootchy
D
7

Yes you are right i guess with the default Notifiable trait, you could create a custom channel.

You can check the Illuminate\Notifications\Channels\DatabaseChannel class for default creation and adopt it to a pivot-table one.

Hope this helps to create a new channel with a pivot table. Also, implement a HasDatabasePivotNotifications trait (or similar name) to your own Notifiable trait.

Dashboard answered 22/12, 2016 at 9:23 Comment(4)
Do you mind sharing specific code to show how it would be possible to implement?Howie
I added a 100 point bounty, since I am not 100% sure how to implement your solution. Can you flesh out your solution with sample code/pseudocode so I can implement it and report the results? A lot of people seem interested in this based on the upvotes :)Howie
i will add till sunday (from now 3 days)Dashboard
@Dashboard Could you still show the implementation?Coker

© 2022 - 2024 — McMap. All rights reserved.