In the last tutorial, we saw how to set up the ability for a user to subscribe to a thread. So what does that mean for the user? Well, if they are subscribed to the thread, then they likely would like to stay up to date with any new replies or activity on that thread. In this tutorial, we’ll look at how to set up Notifications. For example, any time the thread gets a new reply, we could automatically send an email or simply populate a notifications component on the website itself. Let’s see how to set up this new feature now.
Preparing Notifications For A Subscribed User
We are going to lean on the built in Notifiable trait which Laravel uses to set up all kinds of notifications. In addition, artisan has a tool to set up the scaffolding needed to get this working.
vagrant@homestead:~/Code/forumio$ php artisan help notifications:table Usage: notifications:table Options: -h, --help Display this help message -q, --quiet Do not output any message -V, --version Display this application version --ansi Force ANSI output --no-ansi Disable ANSI output -n, --no-interaction Do not ask any interactive question --env[=ENV] The environment the command should run under -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Help: Create a migration for the notifications table
Ok, so let’s go ahead and run that command to get started, as well as migrate the database.
vagrant@homestead:~/Code/forumio$ php artisan notifications:table Migration created successfully! vagrant@homestead:~/Code/forumio$ php artisan migrate Migrating: 2018_02_21_171105_create_notifications_table Migrated: 2018_02_21_171105_create_notifications_table
Visiting the database now shows us a new notifications
table that has all the fields needed to get started working with notifications. We can now use this table for notifications to subscribers, or perhaps notifications to mentioned users.
Adding A Notification Class
Setting up the database table is the first part of setting up notifications, creating a notification class is the next step. There is also an artisan command to help with that so let’s go ahead and make use of it. We will create a ThreadWasUpdated notification class.
vagrant@homestead:~/Code/forumio$ php artisan make:notification ThreadWasUpdated Notification created successfully.
This sets up a new Notifications directory in the project, and the new class if placed inside.
You’ll need to update the contents of the new file to contain this code.
<?php
namespace AppNotifications;
use IlluminateNotificationsNotification;
class ThreadWasUpdated extends Notification
{
protected $thread;
protected $reply;
public function __construct($thread, $reply)
{
$this->thread = $thread;
$this->reply = $reply;
}
public function via()
{
return ['database'];
}
public function toArray()
{
return [
'message' => $this->reply->owner->name . ' replied to ' . $this->thread->title,
'link' => $this->reply->path()
];
}
}
A ThreadSubscription belongs to a user, so we need to update the ThreadSubscription model to reflect that. While we are at it, we can also add a thread relationship, and a notify() method like so.
<?php
namespace App;
use AppNotificationsThreadWasUpdated;
use IlluminateDatabaseEloquentModel;
class ThreadSubscription extends Model
{
protected $guarded = [];
public function user()
{
return $this->belongsTo(User::class);
}
public function thread()
{
return $this->belongsTo(Thread::class);
}
public function notify($reply)
{
$this->user->notify(new ThreadWasUpdated($this->thread, $reply));
}
}
Notifications Need Endpoints
We’ll need to add a couple of routes to the web.php file to support getting and deleting notifications.
<?php
Route::get('/', function () {
return view('welcome');
});
Auth::routes();
Route::get('/home', 'HomeController@index')->name('home');
Route::get('/threads', 'ThreadsController@index');
Route::get('/threads/create', 'ThreadsController@create');
Route::get('/threads/{channel}/{thread}', 'ThreadsController@show');
Route::delete('/threads/{channel}/{thread}', 'ThreadsController@destroy');
Route::post('/threads', 'ThreadsController@store');
Route::get('/threads/{channel}', 'ThreadsController@index');
Route::get('/threads/{channel}/{thread}/replies', 'RepliesController@index');
Route::post('/threads/{channel}/{thread}/replies', 'RepliesController@store');
Route::patch('/replies/{reply}', 'RepliesController@update');
Route::delete('/replies/{reply}', 'RepliesController@destroy');
Route::post('/threads/{channel}/{thread}/subscriptions', 'ThreadSubscriptionsController@store')->middleware('auth');
Route::delete('/threads/{channel}/{thread}/subscriptions', 'ThreadSubscriptionsController@destroy')->middleware('auth');
Route::post('/replies/{reply}/favorites', 'FavoritesController@store');
Route::delete('/replies/{reply}/favorites', 'FavoritesController@destroy');
Route::get('/profiles/{user}', 'ProfilesController@show')->name('profile');
Route::get('/profiles/{user}/notifications', 'UserNotificationsController@index');
Route::delete('/profiles/{user}/notifications/{notification}', 'UserNotificationsController@destroy');
This means we also need a new controller named UserNotificationsController with an index() and destroy() method in place.
vagrant@homestead:~/Code/forumio$ php artisan make:controller UserNotificationsController Controller created successfully.
We’ll add the following code as well.
<?php
namespace AppHttpControllers;
class UserNotificationsController extends Controller
{
public function __construct()
{
$this->middleware('auth');
}
public function index()
{
return auth()->user()->unreadNotifications;
}
public function destroy($user, $notificationId)
{
auth()->user()->notifications()->findOrFail($notificationId)->markAsRead();
}
}
Using Events For New Notifications
We can make use of events in Laravel to help with configuring notifications. Here is what we can do. First off, we’ll visit EventServiceProvider.php and make a couple of modifications.
<?php
namespace AppProviders;
use IlluminateFoundationSupportProvidersEventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
'AppEventsThreadReceivedNewReply' => [
'AppListenersNotifySubscribers'
],
];
public function boot()
{
parent::boot();
//
}
}
With the $listen variable populated, we can now run the event generate command to build out the needed files for us automatically.
vagrant@homestead:~/Code/forumio$ php artisan event:generate Events and listeners generated successfully!
This added two new directories with the associated files needed.
We can populate the code as follows for both ThreadReceivedNewReply.php and NotifySubscribers.php.
<?php
namespace AppEvents;
use IlluminateFoundationEventsDispatchable;
use IlluminateQueueSerializesModels;
class ThreadReceivedNewReply
{
use Dispatchable, SerializesModels;
public $reply;
public function __construct($reply)
{
$this->reply = $reply;
}
}
<?php
namespace AppListeners;
use AppEventsThreadReceivedNewReply;
class NotifySubscribers
{
public function handle(ThreadReceivedNewReply $event)
{
$event->reply->thread->subscriptions
->where('user_id', '!=', $event->reply->user_id)
->each
->notify($event->reply);
}
}
Firing The New Event
Now, the notification should be created when a new reply is added. We already have an addReply() method on the Thread model so let’s see how we can update that method to help us here. We will make use of events in Laravel to help us out.
<?php
namespace App;
use AppEventsThreadReceivedNewReply;
use IlluminateDatabaseEloquentModel;
use TestsFeatureActivityTest;
class Thread extends Model
{
use RecordsActivity;
protected $guarded = [];
protected $with = ['creator', 'channel'];
protected $appends = ['isSubscribedTo'];
protected static function boot()
{
parent::boot();
static::addGlobalScope('replyCount', function ($builder) {
$builder->withCount('replies');
});
static::deleting(function ($thread) {
$thread->replies->each->delete();
});
}
public function path()
{
return '/threads/' . $this->channel->slug . '/' . $this->id;
}
public function replies()
{
return $this->hasMany(Reply::class);
}
public function creator()
{
return $this->belongsTo(User::class, 'user_id');
}
public function channel()
{
return $this->belongsTo(Channel::class);
}
public function addReply($reply)
{
$reply = $this->replies()->create($reply);
event(new ThreadReceivedNewReply($reply));
return $reply;
}
public function scopeFilter($query, $filters)
{
return $filters->apply($query);
}
public function subscribe($userId = null)
{
$this->subscriptions()->create([
'user_id' => $userId ?: auth()->id()
]);
return $this;
}
public function unsubscribe($userId = null)
{
$this->subscriptions()
->where('user_id', $userId ?: auth()->id())
->delete();
}
public function subscriptions()
{
return $this->hasMany(ThreadSubscription::class);
}
public function getIsSubscribedToAttribute()
{
return $this->subscriptions()
->where('user_id', auth()->id())
->exists();
}
}
But Does It Work?
We’re following along with Laracasts here, and we can use the test class that Jeff created to see if this all works. Here is the NotificationsTest class which takes care of all the needed tests for notifications.
<?php
namespace TestsFeature;
use IlluminateFoundationTestingDatabaseMigrations;
use IlluminateNotificationsDatabaseNotification;
use TestsTestCase;
class NotificationsTest extends TestCase
{
use DatabaseMigrations;
public function setUp()
{
parent::setUp();
$this->signIn();
}
function test_a_notification_is_prepared_when_a_subscribed_thread_receives_a_new_reply_that_is_not_by_the_current_user()
{
$thread = create('AppThread')->subscribe();
$this->assertCount(0, auth()->user()->notifications);
$thread->addReply([
'user_id' => auth()->id(),
'body' => 'Some reply here'
]);
$this->assertCount(0, auth()->user()->fresh()->notifications);
$thread->addReply([
'user_id' => create('AppUser')->id,
'body' => 'Some reply here'
]);
$this->assertCount(1, auth()->user()->fresh()->notifications);
}
function test_a_user_can_fetch_their_unread_notifications()
{
create(DatabaseNotification::class);
$this->assertCount(
1,
$this->getJson("/profiles/" . auth()->user()->name . "/notifications")->json()
);
}
function test_a_user_can_mark_a_notification_as_read()
{
create(DatabaseNotification::class);
tap(auth()->user(), function ($user) {
$this->assertCount(1, $user->unreadNotifications);
$this->delete("/profiles/{$user->name}/notifications/" . $user->unreadNotifications->first()->id);
$this->assertCount(0, $user->fresh()->unreadNotifications);
});
}
}
Note that we need to update our Model Factory as well. In our case, we simply defined everything in the UserFactory.php file. Note the new addition.
<?php
use FakerGenerator as Faker;
$factory->define(AppUser::class, function (Faker $faker) {
static $password;
return [
'name' => $faker->name,
'email' => $faker->unique()->safeEmail,
'password' => $password ?: $password = bcrypt('secret'),
'remember_token' => str_random(10),
];
});
$factory->define(AppThread::class, function ($faker) {
return [
'user_id' => function () {
return factory('AppUser')->create()->id;
},
'channel_id' => function () {
return factory('AppChannel')->create()->id;
},
'title' => $faker->sentence,
'body' => $faker->paragraph
];
});
$factory->define(AppChannel::class, function ($faker) {
$name = $faker->word;
return [
'name' => $name,
'slug' => $name
];
});
$factory->define(AppReply::class, function ($faker) {
return [
'thread_id' => function () {
return factory('AppThread')->create()->id;
},
'user_id' => function () {
return factory('AppUser')->create()->id;
},
'body' => $faker->paragraph
];
});
$factory->define(IlluminateNotificationsDatabaseNotification::class, function ($faker) {
return [
'id' => RamseyUuidUuid::uuid4()->toString(),
'type' => 'AppNotificationsThreadWasUpdated',
'notifiable_id' => function () {
return auth()->id() ?: factory('AppUser')->create()->id;
},
'notifiable_type' => 'AppUser',
'data' => ['foo' => 'bar']
];
});
Running the full suite in this class indicates that everything works!
vagrant@homestead:~/Code/forumio$ phpunit --filter NotificationsTest PHPUnit 6.5.5 by Sebastian Bergmann and contributors. ... 3 / 3 (100%) Time: 1.13 seconds, Memory: 10.00MB OK (3 tests, 6 assertions)
In addition, we can test in the browser. We have a thread that user Nikola Tesla is subscribed to. We are going to log in as a different user, and post a reply to that thread. When that happens, then we should see a new notification in the database. In fact, it looks to be working in this scenario as well.
After the above reply was made, we check the database notifications table to see that a new notification has been generated.
Rendering Notifications With VueJS
We will render the notification in the navigation bar on the right hand side. The component will only be rendered for a logged in user, so you can add the markup like so in nav.blade.php.
<!-- Right Side Of Navbar -->
<ul class="nav navbar-nav navbar-right">
<!-- Authentication Links -->
@guest
<li><a href="{{ route('login') }}">Login</a></li>
<li><a href="{{ route('register') }}">Register</a></li>
@else
<user-notifications></user-notifications>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false" aria-haspopup="true">
{{ Auth::user()->name }} <span class="caret"></span>
</a>
<ul class="dropdown-menu">
<li><a href="{{ route('profile', Auth::user()) }}">My Threads</a></li>
<li>
<a href="{{ route('logout') }}"
onclick="event.preventDefault();
document.getElementById('logout-form').submit();">
Logout
</a>
<form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
{{ csrf_field() }}
</form>
</li>
</ul>
</li>
@endguest
</ul>
Knowing that we are now going to be building a new Vue component, we should boot up the watcher like so.
vagrant@homestead:~/Code/forumio$ yarn run watch-poll yarn run v1.3.2 $ node node_modules/cross-env/dist/bin/cross-env.js NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --watch-poll --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js
Let’s register this new <user-notifications> component as a global component in app.js.
require('./bootstrap');
window.Vue = require('vue');
Vue.component('flash', require('./components/Flash.vue'));
Vue.component('paginator', require('./components/Paginator.vue'));
Vue.component('user-notifications', require('./components/UserNotifications.vue'));
Vue.component('thread-view', require('./pages/Thread.vue'));
const app = new Vue({
el: '#app'
});
We can now create the component file like so.
UserNotifications.vue
Within the component is the following markup.
<template>
<li class="dropdown" v-if="notifications.length">
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
<span class="glyphicon glyphicon-bell"></span>
</a>
<ul class="dropdown-menu">
<li v-for="notification in notifications">
<a :href="notification.data.link"
v-text="notification.data.message"
@click="markAsRead(notification)"
></a>
</li>
</ul>
</li>
</template>
<script>
export default {
data() {
return {notifications: false}
},
created() {
axios.get('/profiles/' + window.App.user.name + '/notifications')
.then(response => this.notifications = response.data);
},
methods: {
markAsRead(notification) {
axios.delete('/profiles/' + window.App.user.name + '/notifications/' + notification.id)
}
}
}
</script>
We have everything in place now to take care of rendering notifications to the user. Just before this, we had the user Nikola Tesla subscribed to a thread of “The Weather is Beautiful Outside”. Then, in a different session, we logged in as a different user Tom and left a new reply. This means that Nikola Tesla should be notified of a new activity on that thread. Did it work?
Yes! We can see that when logged in as Nikola Tesla, a new notification is displayed in the navbar and it has a link directly to the thread in question. Fantastic.
Setup Notifications For Subscribed Users Summary
We now know how to both setup and render notification for users that are subscribed to a particular thread. This tutorial really goes hand in hand with the Subscription System Tutorial where we learned how to first give users the ability to subscribe to a thread. Now we are providing meaningful notifications for those subscribed users.