I wanted to implement a notification system for our school, it's a php/mysql webapp that is not opened for public, so it doesn't receive much traffic. "daily 500-1000 visitor".
1. My initial approach was using MYSQL triggers:
I used a Mysql AFTER INSERT trigger
to add records to a table named notifications
. Something like.
'CREATE TRIGGER `notify_new_homwork` AFTER INSERT ON `homeworks`
FOR EACH ROW INSERT INTO `notifications`
( `from_id`, `note`, `class_id`)
VALUES
(new.user_id,
concat('A New homework Titled: "',left(new.title,'50'),
'".. was added' )
,new.subject_id , 11);'
This kind of black magic worked very well, yet i couldn't keep track of if this notification is new "to show count of new notifications for user". so i added a page named notifications.
Notifications are retrieved with something like
SELECT n.* from notifications n
JOIN user_class on user_class.class_id = n.class_id where user_class.user_id = X;
Note: table user_class link user to class "user_id,class_id,subject_id" -subject is null unless user is a teacher'
Now my next challenges are.
example if 2 user commented on something, then do not insert a new row, just update the old one with something like 'userx and 1 other commented on hw'.
Thanks alot
Edit
As per answer below, to set a read/unread flag on row, i will need to have a row for each student not just a row for the whole class.. which means editing the trigger to something like
insert into notifications (from_id,note,student_id,isread)
select new.user_id,new.note,user_id,'0' from user_class where user_class.class_id = new.class_id group by user_class.user_id
Well this question is 9 months old so i'm not sure if OP is still in the need of an answer but due the many views and the tasty bounty I would like to also add my mustard (German saying..).
In this post I will try to make a simple explained example on how to start building a notification system.
Edit: Well ok this turned out way, way, way longer than I expected it to be. I got really tired in the end, i'm sorry.
Question 1: have a flag on every notification.
Question 2: Still store every notification as a single record inside your database and group them when they are requested.
I assume that the notifications will look something like:
+---------------------------------------------+
| ▣ James has uploaded new Homework: Math 1+1 |
+---------------------------------------------+
| ▣ Jane and John liked your comment: Im s... |
+---------------------------------------------+
| ▢ The School is closed on independence day. |
+---------------------------------------------+
Behind the curtains this could look something like this:
+--------+-----------+--------+-----------------+-------------------------------------------+
| unread | recipient | sender | type | reference |
+--------+-----------+--------+-----------------+-------------------------------------------+
| true | me | James | homework.create | Math 1 + 1 |
+--------+-----------+--------+-----------------+-------------------------------------------+
| true | me | Jane | comment.like | Im sick of school |
+--------+-----------+--------+-----------------+-------------------------------------------+
| true | me | John | comment.like | Im sick of school |
+--------+-----------+--------+-----------------+-------------------------------------------+
| false | me | system | message | The School is closed on independence day. |
+--------+-----------+--------+-----------------+-------------------------------------------+
Note: I don't recommend to group the notifications inside the database, do that on runtime this keeps things a lot more flexible.
Every system I have been working on had a simple 1 to 1 reference relationship on a notification, you might have an 1 to n keep in mind that I will continue my example with 1:1. This also means that I don't need a field defining what type of object is referenced because this is defined by the notification type.
Now when defining a real table structure for SQL we come to a few decisions in terms of the database design. I will go with simplest solution which will look something like this:
+--------------+--------+---------------------------------------------------------+
| column | type | description |
+--------------+--------+---------------------------------------------------------+
| id | int | Primary key |
+--------------+--------+---------------------------------------------------------+
| recipient_id | int | The receivers user id. |
+--------------+--------+---------------------------------------------------------+
| sender_id | int | The sender's user id. |
+--------------+--------+---------------------------------------------------------+
| unread | bool | Flag if the recipient has already read the notification |
+--------------+--------+---------------------------------------------------------+
| type | string | The notification type. |
+--------------+--------+---------------------------------------------------------+
| parameters | array | Additional data to render different notification types. |
+--------------+--------+---------------------------------------------------------+
| reference_id | int | The primary key of the referencing object. |
+--------------+--------+---------------------------------------------------------+
| created_at | int | Timestamp of the notification creation date. |
+--------------+--------+---------------------------------------------------------+
Or for the lazy folks the SQL create table command for this example:
CREATE TABLE `notifications` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT,
`recipient_id` int(11) NOT NULL,
`sender_id` int(11) NOT NULL,
`unread` tinyint(1) NOT NULL DEFAULT '1',
`type` varchar(255) NOT NULL DEFAULT '',
`parameters` text NOT NULL,
`reference_id` int(11) NOT NULL,
`created_at` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
This implementation depends completely on the needs of your application, Note: This is an example not the golden standard on how to build an notification system in PHP.
This is an example base model of the notification itself, nothing fancy just the needed properties and the abstract methods messageForNotification
and messageForNotifications
we expected being implemented in the different notification types.
abstract class Notification
{
protected $recipient;
protected $sender;
protected $unread;
protected $type;
protected $parameters;
protected $referenceId;
protected $createdAt;
/**
* Message generators that have to be defined in subclasses
*/
public function messageForNotification(Notification $notification) : string;
public function messageForNotifications(array $notifications) : string;
/**
* Generate message of the current notification.
*/
public function message() : string
{
return $this->messageForNotification($this);
}
}
You will have to add a constructor, getters, setters and that kind of stuff by yourself in your own style, i'm not going to provide a ready to use Notification system.
Now you can create a new Notification
subclass for every type. This following example would handle the like action of a comment:
Example implementation:
namespace Notification\Comment;
class CommentLikedNotification extends \Notification
{
/**
* Generate a message for a single notification
*
* @param Notification $notification
* @return string
*/
public function messageForNotification(Notification $notification) : string
{
return $this->sender->getName() . 'has liked your comment: ' . substr($this->reference->text, 0, 10) . '...';
}
/**
* Generate a message for a multiple notifications
*
* @param array $notifications
* @return string
*/
public function messageForNotifications(array $notifications, int $realCount = 0) : string
{
if ($realCount === 0) {
$realCount = count($notifications);
}
// when there are two
if ($realCount === 2) {
$names = $this->messageForTwoNotifications($notifications);
}
// less than five
elseif ($realCount < 5) {
$names = $this->messageForManyNotifications($notifications);
}
// to many
else {
$names = $this->messageForManyManyNotifications($notifications, $realCount);
}
return $names . ' liked your comment: ' . substr($this->reference->text, 0, 10) . '...';
}
/**
* Generate a message for two notifications
*
* John and Jane has liked your comment.
*
* @param array $notifications
* @return string
*/
protected function messageForTwoNotifications(array $notifications) : string
{
list($first, $second) = $notifications;
return $first->getName() . ' and ' . $second->getName(); // John and Jane
}
/**
* Generate a message many notifications
*
* Jane, Johnny, James and Jenny has liked your comment.
*
* @param array $notifications
* @return string
*/
protected function messageForManyNotifications(array $notifications) : string
{
$last = array_pop($notifications);
foreach($notifications as $notification) {
$names .= $notification->getName() . ', ';
}
return substr($names, 0, -2) . ' and ' . $last->getName(); // Jane, Johnny, James and Jenny
}
/**
* Generate a message for many many notifications
*
* Jonny, James and 12 other have liked your comment.
*
* @param array $notifications
* @return string
*/
protected function messageForManyManyNotifications(array $notifications, int $realCount) : string
{
list($first, $second) = array_slice($notifications, 0, 2);
return $first->getName() . ', ' . $second->getName() . ' and ' . $realCount . ' others'; // Jonny, James and 12 other
}
}
To work with your notifications inside your application create something like a notification manager:
class NotificationManager
{
protected $notificationAdapter;
public function add(Notification $notification);
public function markRead(array $notifications);
public function get(User $user, $limit = 20, $offset = 0) : array;
}
The notificationAdapter
property should contain the logic in direct communication with your data backend in the case of this example mysql.
Using mysql
triggers is not wrong, because there is no wrong solution. What works, works.. But I strongly recommend to not let the database handle application logic.
So inside the notification manager you might want to do something like this:
public function add(Notification $notification)
{
// only save the notification if no possible duplicate is found.
if (!$this->notificationAdapter->isDoublicate($notification))
{
$this->notificationAdapter->add([
'recipient_id' => $notification->recipient->getId(),
'sender_id' => $notification->sender->getId()
'unread' => 1,
'type' => $notification->type,
'parameters' => $notification->parameters,
'reference_id' => $notification->reference->getId(),
'created_at' => time(),
]);
}
}
Behind the add
method of the notificationAdapter
can be a raw mysql insert command. Using this adapter abstraction enables you to switch easily from mysql to a document based database like mongodb which would make sense for a Notification system.
The isDoublicate
method on the notificationAdapter
should simply check if there is already a notification with the same recipient
, sender
, type
and reference
.
I cannot point out enough that this is only a example. (Also I really have to shorten the next steps this post is getting ridiculously long -.-)
So assuming you have some kind of controller with an action when a teacher uploads homework:
function uploadHomeworkAction(Request $request)
{
// handle the homework and have it stored in the var $homework.
// how you handle your services is up to you...
$notificationManager = new NotificationManager;
foreach($homework->teacher->students as $student)
{
$notification = new Notification\Homework\HomeworkUploadedNotification;
$notification->sender = $homework->teacher;
$notification->recipient = $student;
$notification->reference = $homework;
// send the notification
$notificationManager->add($notification);
}
}
Will create a notification for every teacher's student when he uploads a new homework.
Now comes the hard part. The problem with grouping on the PHP side is that you will have to load all notifications of the current user to group them correctly. This would be bad, well if you have only a few users it would probably still be no problem, but that doesn't make it good.
The easy solution is to simply limit the number of notifications requested and only grouping these. This will work fine when there are not many similar notifications (like 3-4 per 20). But lets say the post of a user / student gets about a hundred likes and you only select the last 20 notifications. The user will then only see that 20 people liked his post also that would be his only notification.
A "correct" solution would be grouping the notifications already on the database and selecting only some samples per notification group. Than you would just have to inject the real count into your notification messages.
You probably didn't read the text below so let me continue with a snippet:
select *, count(*) as count from notifications
where recipient_id = 1
group by `type`, `reference_id`
order by created_at desc, unread desc
limit 20
Now you know what notifications should be around for the given user and how many notifications the group contains.
And now the shitty part. I still could not find out a better way to select a limited number of notifications for each group without doing a query for every group. All suggestions here are very welcome.
So I do something like:
$notifcationGroups = [];
foreach($results as $notification)
{
$notifcationGroup = ['count' => $notification['count']];
// when the group only contains one item we don't
// have to select it's children
if ($notification['count'] == 1)
{
$notifcationGroup['items'] = [$notification];
}
else
{
// example with query builder
$notifcationGroup['items'] = $this->select('notifications')
->where('recipient_id', $recipient_id)
->andWehere('type', $notification['type'])
->andWhere('reference_id', $notification['reference_id'])
->limit(5);
}
$notifcationGroups[] = $notifcationGroup;
}
I will now continue assuming that the notificationAdapter
s get
method implements this grouping and returns an array like this:
[
{
count: 12,
items: [Note1, Note2, Note3, Note4, Note5]
},
{
count: 1,
items: [Note1]
},
{
count: 3,
items: [Note1, Note2, Note3]
}
]
Because we always have at least one notification in our group and our ordering prefers Unread and New notifications we can just use the first notification as a sample for rendering.
So to be able to work with these grouped notifications we need a new object:
class NotificationGroup
{
protected $notifications;
protected $realCount;
public function __construct(array $notifications, int $count)
{
$this->notifications = $notifications;
$this->realCount = $count;
}
public function message()
{
return $this->notifications[0]->messageForNotifications($this->notifications, $this->realCount);
}
// forward all other calls to the first notification
public function __call($method, $arguments)
{
return call_user_func_array([$this->notifications[0], $method], $arguments);
}
}
And finally we can actually put most of the stuff together. This is how the get function on the NotificationManager
might look like:
public function get(User $user, $limit = 20, $offset = 0) : array
{
$groups = [];
foreach($this->notificationAdapter->get($user->getId(), $limit, $offset) as $group)
{
$groups[] = new NotificationGroup($group['notifications'], $group['count']);
}
return $gorups;
}
And really finally inside a possible controller action:
public function viewNotificationsAction(Request $request)
{
$notificationManager = new NotificationManager;
foreach($notifications = $notificationManager->get($this->getUser()) as $group)
{
echo $group->unread . ' | ' . $group->message() . ' - ' . $group->createdAt() . "\n";
}
// mark them as read
$notificationManager->markRead($notifications);
}