cult3

Setting up a Notification system in PHP

Jan 26, 2015

Table of contents:

  1. Recap of what we’re going to build
  2. The bigger picture of the Notifications System
  3. The Notification Entity
  4. Notification Types
  5. Conclusion

A notification system is a very common component of consumer web applications. User notifications are an important driver of growth and usage in web applications and can really make the difference in encouraging users to return and engage with your product.

Last week we looked at Modelling a Notification System. The raw components of a notification system are not that difficult to create. However, notification systems can be deceptively simple, but with hidden complexity.

In today’s article we’re going to be looking at setting up the foundation of the notification system for Cribbb.

Recap of what we’re going to build

Before we jump into the code, first we’ll recap what we decided upon from last week’s article on modelling the notification system.

Notifications are an important aspect of user identity because a user can only retrieve their own notifications and not the notifications of other users. This functionality therefore belongs to the Identity Bounded Context.

A Notification is an Entity because it has a lifecycle and should be identified by an id. Each notification has meaning within the application and all notifications go through a lifecycle of changes.

Finally we also need to be able to filter notifications into different types. This will allow users to filter what type of notifications they want to receive as emails and what notifications can simply be displayed as part of the User Interface.

The bigger picture of the Notifications System

The notification system of Cribbb will sit in the Identity Bounded Context but it will really transcend through the entire application to act as a pipeline for system notifications.

When certain functionality has this kind of breadth, I think it’s important to take a step back and think about how it relates to the application as a whole.

Notifications will hook on to the Domain Events that are fired during notable moments of the application’s execution. By using Domain Events we can add or remove notifications without disturbing the cause of the event.

When a notification listener is triggered a new Notification will be created and saved to the database. This Notification Entity will hold all the details of the event that took place to inform the user.

At this point the notifications will be pushed on to a queue. Certain actions in Cribbb (such as replying to a thread) could potentially cause a lot of notifications. Notifications do not need to be instantaneous and so we can relieve some of the pressure by pushing the notifications on to a queue.

The queue can then work through each of the notifications to decide what to do with it. Users should have the option to filter what notifications they receive via email. During the processing of the queued notifications, it should be determined whether the current notification should be sent as an email to the user.

The Notification Entity

So the first thing we need to do is to create the NotificationId Identifier and Notification Entity. As with the previous Identifiers and Entities, these are fairly standard to begin.

Instead of repeating myself from previous tutorials, I’ll show you the code for each of these files, but I will only point out the interesting things.

Firstly we have the NotificationId:

<?php namespace Cribbb\Domain\Model\Identity;

use Rhumsaa\Uuid\Uuid;
use Cribbb\Domain\Identifier;
use Cribbb\Domain\UuidIdentifier;

class NotificationId extends UuidIdentifier implements Identifier
{
    /**
     * @var Uuid
     */
    protected $value;

    /**
     * Create a new NotificationId
     *
     * @return void
     */
    public function __construct(Uuid $value)
    {
        $this->value = $value;
    }
}

And the NotificationIdTest:

<?php namespace Cribbb\Tests\Domain\Model\Identity;

use Rhumsaa\Uuid\Uuid;
use Cribbb\Domain\Model\Identity\NotificationId;

class NotificationIdTest extends \PHPUnit_Framework_TestCase
{
    /** @test */
    public function should_require_instance_of_uuid()
    {
        $this->setExpectedException("Exception");

        $id = new NotificationId();
    }

    /** @test */
    public function should_create_new_id()
    {
        $id = new NotificationId(Uuid::uuid4());

        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Identity\NotificationId",
            $id
        );
    }

    /** @test */
    public function should_generate_new_id()
    {
        $id = NotificationId::generate();

        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Identity\NotificationId",
            $id
        );
    }

    /** @test */
    public function should_create_id_from_string()
    {
        $id = NotificationId::fromString(
            "d16f9fe7-e947-460e-99f6-2d64d65f46bc"
        );

        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Identity\NotificationId",
            $id
        );
    }

    /** @test */
    public function should_test_equality()
    {
        $one = NotificationId::fromString(
            "d16f9fe7-e947-460e-99f6-2d64d65f46bc"
        );
        $two = NotificationId::fromString(
            "d16f9fe7-e947-460e-99f6-2d64d65f46bc"
        );
        $three = NotificationId::generate();

        $this->assertTrue($one->equals($two));
        $this->assertFalse($one->equals($three));
    }

    /** @test */
    public function should_return_id_as_string()
    {
        $id = NotificationId::fromString(
            "d16f9fe7-e947-460e-99f6-2d64d65f46bc"
        );

        $this->assertEquals(
            "d16f9fe7-e947-460e-99f6-2d64d65f46bc",
            $id->toString()
        );
        $this->assertEquals(
            "d16f9fe7-e947-460e-99f6-2d64d65f46bc",
            (string) $id
        );
    }
}

If you have been following along with this series, these two files should look very familiar to you by now.

Next we have the Notification Entity:

<?php namespace Cribbb\Domain\Model\Identity;

use Cribbb\Domain\RecordsEvents;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;

/**
 * @ORM\Entity
 * @ORM\Table(name="notifications")
 */
class Notification
{
    /**
     * @ORM\Id
     * @ORM\Column(type="string")
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity="Cribbb\Domain\Model\Identity\User", inversedBy="notifications")
     */
    private $user;

    /**
     * @ORM\Column(type="text")
     */
    private $body;

    /**
     * Create a new Notification
     *
     * @param NotificationId $id
     * @param User $user
     * @param string $body
     * @return void
     */
    public function __construct(NotificationId $id, User $user, $body)
    {
        $this->setId($id);
        $this->setUser($user);
        $this->setBody($body);
    }

    /**
     * Get the Notification id
     *
     * @return NotificationId
     */
    public function id()
    {
        return NotificationId::fromString($this->id);
    }

    /**
     * Set the Notification id
     *
     * @param NotificationId $id
     * @return void
     */
    private function setId(NotificationId $id)
    {
        $this->id = $id->toString();
    }

    /**
     * Get the User
     *
     * @return User
     */
    public function user()
    {
        return $this->user;
    }

    /**
     * Set the User
     *
     * @param User $user
     * @return void
     */
    private function setUser(User $user)
    {
        $this->user = $user;
    }

    /**
     * Get the body
     *
     * @return string
     */
    public function body()
    {
        return $this->body;
    }

    /**
     * Set the body
     *
     * @param string $body
     * @return void
     */
    private function setBody($body)
    {
        $this->body = $body;
    }
}

Once again this is a fairly standard example of a Doctrine Entity, However you will notice that this Entity does not implement the AggregateRoot interface and it does not implement the RecordsEvents trait.

This Entity has the standard getters and setters for it’s class properties and it also has a ManyToOne relationship with the User Entity.

Here are the tests for the Notification Entity:

<?php namespace Cribbb\Tests\Domain\Model\Identity;

use Cribbb\Domain\Model\Identity\User;
use Cribbb\Domain\Model\Identity\Email;
use Cribbb\Domain\Model\Identity\UserId;
use Cribbb\Domain\Model\Identity\Username;
use Cribbb\Domain\Model\Identity\Notification;
use Cribbb\Domain\Model\Identity\NotificationId;
use Cribbb\Domain\Model\Identity\HashedPassword;

class NotificationTest extends \PHPUnit_Framework_TestCase
{
    /** @var User */
    private $user;

    public function setUp()
    {
        $this->user = User::register(
            UserId::generate(),
            new Email("name@domain.com"),
            new Username("username"),
            new HashedPassword("password")
        );
    }

    /** @test */
    public function should_require_id()
    {
        $this->setExpectedException("Exception");

        $notification = new Notification(null, $this->user, "hello world");
    }

    /** @test */
    public function should_require_user()
    {
        $this->setExpectedException("Exception");

        $notification = new Notification(
            NotificationId::generate(),
            null,
            "hello world"
        );
    }

    /** @test */
    public function should_require_body()
    {
        $this->setExpectedException("Exception");

        $notification = new Notification(
            NotificationId::generate(),
            $this->user
        );
    }

    /** @test */
    public function should_create_notification()
    {
        $notification = new Notification(
            NotificationId::generate(),
            $this->user,
            "hello world"
        );

        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Identity\Notification",
            $notification
        );
        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Identity\NotificationId",
            $notification->id()
        );
        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Identity\User",
            $notification->user()
        );
        $this->assertEquals("hello world", $notification->body());
    }
}

At the initial stage of this Entity, there isn’t a great deal to test. The tests above are simply ensuring the Notification Entity can be instantiated correctly and it throws Exceptions if any of the requirements are not met.

Notification Types

One of the important business rules that we must satisfy is ensuring that instances of Notification have a correct type that can be used to filter against.

In the early days of the application we will likely have a small selection of notification types. However, over time we are more than likely going to need to add to this selection.

I therefore think it makes sense to implement these notification types as objects.

By using objects we can ensure that all Notification instances are one of these select types. However, by defining an interface, we can very easily add to this selection over time.

So the first thing to do is to define the NotificationType interface:

<?php namespace Cribbb\Domain\Model\Identity;

interface NotificationType
{
    /**
     * Return the Notification Type tag
     *
     * @return string
     */
    public function tag();
}

We will likely need to add further methods to this interface over time, but for now all we need to do is to return the tag that will be saved into the database.

Next we can define the first type that will implement this interface.

First create a new namespace under Identity called NotificationTypes.

Next copy the following code:

<?php namespace Cribbb\Domain\Model\Identity\NotificationTypes;

use Cribbb\Domain\Model\Identity\NotificationType;

class NewFollower implements NotificationType
{
    /**
     * Return the Notification Type tag
     *
     * @return string
     */
    public function tag()
    {
        return "new_follower";
    }
}

The test for this class is also very simple as we only need to assert that the correct tag is returned from the tag() method:

<?php namespace Cribbb\Tests\Domain\Model\Identity\NotificationTypes;

use Cribbb\Domain\Model\Identity\NotificationTypes\NewFollower;

class NewFollowerTest extends \PHPUnit_Framework_TestCase
{
    /** @test */
    public function should_return_tag()
    {
        $type = new NewFollower();

        $this->assertEquals("new_follower", $type->tag());
    }
}

Finally we need to require an instance of NotificationType in the Notification Entity __construct() method:

/**
 * @ORM\Column(type="string")
 */
private $type;

/**
 * Create a new Notification
 *
 * @param NotificationId $id
 * @param User $user
 * @param NotificationType $type
 * @param string $body
 * @return void
 */
public function __construct(NotificationId $id, User $user, NotificationType $type, $body)
{
    $this->setId($id);
    $this->setUser($user);
    $this->setType($type);
    $this->setBody($body);
}

We will also need to add the getters and setters:

/**
 * Get the Notification Type
 *
 * @return string
 */
public function type()
{
    return $this->type;
}

/**
 * Set the Notification Type
 *
 * @param NotificationType $type
 * @return void
 */
private function setType(NotificationType $type)
{
    $this->type = $type->tag();
}

Note, I’m simply returning a string from the get method. I will more than likely reconstitute the NotificationType Value Object at some point in the future, but for now a string will do just fine.

And finally we can update the NotificationTest file to expect the NotificationType:

<?php namespace Cribbb\Tests\Domain\Model\Identity;

use Cribbb\Domain\Model\Identity\User;
use Cribbb\Domain\Model\Identity\Email;
use Cribbb\Domain\Model\Identity\UserId;
use Cribbb\Domain\Model\Identity\Username;
use Cribbb\Domain\Model\Identity\Notification;
use Cribbb\Domain\Model\Identity\NotificationId;
use Cribbb\Domain\Model\Identity\HashedPassword;
use Cribbb\Domain\Model\Identity\NotificationTypes\NewFollower;

class NotificationTest extends \PHPUnit_Framework_TestCase
{
    /** @var User */
    private $user;

    public function setUp()
    {
        $this->user = User::register(
            UserId::generate(),
            new Email("name@domain.com"),
            new Username("username"),
            new HashedPassword("password")
        );
    }

    /** @test */
    public function should_require_id()
    {
        $this->setExpectedException("Exception");

        $notification = new Notification(
            null,
            $this->user,
            new NewFollower(),
            "hello world"
        );
    }

    /** @test */
    public function should_require_user()
    {
        $this->setExpectedException("Exception");

        $notification = new Notification(
            NotificationId::generate(),
            null,
            new NewFollower(),
            "hello world"
        );
    }

    /** @test */
    public function should_require_notification_type()
    {
        $this->setExpectedException("Exception");

        $notification = new Notification(
            NotificationId::generate(),
            $this->user,
            null,
            "hello world"
        );
    }

    /** @test */
    public function should_require_body()
    {
        $this->setExpectedException("Exception");

        $notification = new Notification(
            NotificationId::generate(),
            $this->user,
            new NewFollower()
        );
    }

    /** @test */
    public function should_create_notification()
    {
        $notification = new Notification(
            NotificationId::generate(),
            $this->user,
            new NewFollower(),
            "hello world"
        );

        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Identity\Notification",
            $notification
        );
        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Identity\NotificationId",
            $notification->id()
        );
        $this->assertInstanceOf(
            "Cribbb\Domain\Model\Identity\User",
            $notification->user()
        );
        $this->assertEquals("new_follower", $notification->type());
        $this->assertEquals("hello world", $notification->body());
    }
}

Conclusion

Notifications are really important to modern consumer web applications because they encourage users to return and interact with your product.

On the surface a notification system can seem really easy. But as with a lot of seemingly simple solutions, there is a certain level of complexity when building a notification system with intricacies.

Good notification systems allow granular control so the end user can decide which notifications she wishes to receive and which can be ignored.

The notification system will also span the entire application as any number of actions should be able to trigger a new notification. Fortunately we can hook on to the Domain Event infrastructure that we already have in place to ensure we can add and remove notification triggers without having to disturb the underlying code.

This is a series of posts on building an entire Open Source application called Cribbb. All of the tutorials will be free to web, and all of the code is available on GitHub.

Philip Brown

@philipbrown

© Yellow Flag Ltd 2024.