Support the ongoing development of Laravel.io →
Article Hero Image

Adding Notifications to Laravel.io with Livewire, Alpine.js and Tailwind UI

29 Jun, 2020 12 min read

Photo by Brett Jordan on Unsplash

Setting the scene

Before I get into the bones of this article, I think it's worth taking the opportunity to share how I got involved with the Laravel.io project.

Last June, I was lucky enough to speak at Laravel Live UK in London. During the speaker dinner, I sat opposite Dries Vints. I've been a user of Laravel since the early days of v4 in 2014 so I was well aware of everything Dries has done for the community even before becoming an employee.

I was also aware that Dries is the maintainer of Laravel.io, a community tool I have used since my first interactions with the framework. I had previously read a post from the Refactoring UI team where the project was used as a redesign case study and asked if he would be interested in somebody implementing the resulting design.

After the conference, I reconnected with Dries and since then, we have completely redesigned the app using that post as inspiration. Subsequently, we've done a huge amount of work to get on top of old pull requests, bumping framework and PHP versions and settling on a tech stack comprised entirely of community projects, Tailwind CSS, Alpine.js, Laravel and Livewire - now affectionately dubbed the TALL stack.

Now you're up to speed, let's talk about the process of building out one of our latest features with the aforementioned tools: notifications.

Inspiration

The app has a public profile page and dashboard page for logged in users. These two pages are very similar in their makeup, with the dashboard just having a few extra features such as the ability to edit your account.

Adding notifications to the app is something which had been a Github issue since March 2018. The obvious place to add them would be to the existing dashboard.

However, before starting on this, I wanted to make some improvements to the existing dashboard and profile pages. They served their purpose, but I found the design a little uninspiring and it would have been difficult to fit notifications into the existing design.

Existing profile page Current profile

Existing dashboard page Current dashboard

I'm no designer so I did what I usually do when I hit a creative block; I tapped up the Internet for inspiration.

I have always liked Github's profile page and seeing as our user base are all developers and we already use Github for social login, I decided to use this format as my reference point.

Github profile page Github profile

Probably my favourite thing about Tailwind is how easy it is to prototype in the browser. After about an hour or so, I sent the following to Dries and we took this as our starting point.

Proposed new layout Proposed new layout

Building the interface with Alpine JS

Now we were settled on a design, it was time to bring it to life. First on the agenda was the tabbed interface.

The idea was we contain the existing 'Latest Threads' and 'Latest Replies' in their own tab, laying the foundation for us to add a 'Notifications' tab later down the line, thus solving my problem of not having anywhere to put them in the existing layout.

Thankfully, Alpine makes this a cinch. If you are unaware, Alpine is a minimal Javascript framework which moves a majority of behaviour to your mark-up. It's a really refreshing approach.

In Alpine, you define a component using the x-data attribute on an HTML element in your markup and setting its value to an object. Inside this object, you can define the initial state of the component. All the properties of this object are accessible to any element inside the component.

<div x-data="{ tab: 'threads' }">
    …
</div>

Here we define a component and initialise its state by setting the tab property to threads. This will be the initial active tab.

Tabs are toggled by clicking buttons in the navigation. Alpine allows us to listen to browser events directly from our mark-up.

<button @click="tab = 'replies'" :class="{ 'active': tab === 'replies' }">
    Latest Threads
</button>

Note: @click is shorthand for the x-on directive. The same result can be achieved with the syntax x-on:click="tab = replies". Similarly, :class is shorthand for the x-bind directive.

There are a couple of things happening in the code snippet above.

First off, we're using the @click directive to change the tab property of our components data object to replies when the button is clicked.

Notice there is no need to access the property using syntax such as this.data.tab or this.props.tab - it is exposed to the component directly as a variable.

Additionally, we are using x-bind directive to dynamically set the elements class to active when the value of the tab property is set to replies.

Now that we are able to manipulate the value of our data object when we interact with the navigation, we can use it to determine which tabs content should be displayed.

In Alpine, this can be achieved by using the x-show directive.

<div x-show="tab === 'threads'">
    // Tab content here…
</div>

x-show will set the elements style attribute to display: none when any Javascript expression passed to it evaluates to false.

In the example above, the element will be hidden until we click the button which sets the tab property on the data object to threads.

Just like that, we have tabs!

Before Current profile

After New profile

Notifications

Now with the tabs in place, it was time to move on to the notifications.

The implementation was done using the database driver for Laravel's native notification system.

The app itself already uses the notification system for sending email notifications, so tapping into this existing functionality to add the database driver was relatively simple.

First, the migration for the notifications table needed to be published.

php artisan notifications:table

Then, the array returned from the via method of the notification class needed to be updated to include the database channel.

public function via(User $user)
{
    return ['mail', 'database'];
}

Finally, it's necessary to shape the data which should be stored in the database. This is done by returning an array from the toArray or toDatabase method of the notification class. Laravel will automatically encode this to JSON and store in the database against the user.

public function toDatabase(User $user)
{
    return [
        'type' => 'new_reply',
        'reply' => $this->reply->id(),
        'replyable_id' => $this->reply->replyable_id,
        'replyable_type' => $this->reply->replyable_type,
        'replyable_subject' => $this->reply->replyAble()->replyAbleSubject(),
    ];
}

Rendering notifications with Livewire

We wanted the interaction with the notifications to be a dynamic experience for users. In particular, we wanted to avoid the page reload when interacting with pagination or marking a notification as read. Additionally, we wanted notification indicators to update in real-time.

Enter Livewire.

My biggest worry when deciding to tackle this with Livewire was the pagination.

Then I consulted the docs and my qualms were no more. Turns out, Livewire ships with this functionality out of the box.

To start, we need to create a new Livewire component.

php artisan livewire:make notifications  

Upon running this command, Livewire will create a new Notifications.php class which will contain the logic of the component.

In this class, a render method needs to be defined which returns a view along with all the data it needs.

As a bonus, the view file is also created as part of the above make command - notifications.blade.php

public function render(): View
{
    return view('livewire.notifications', [
        'notifications' => Auth::user()->unreadNotifications()->paginate(10),
    ]);
}

In the view file, we loop over the notifications collection, creating a new table row for each before rendering the pagination links.

<table class="min-w-full">
    <tbody>
        @foreach ($notifications as $notification)
            @includeIf("notifications.{$notification->data['type']}")
        @endforeach
    </tbody>
</table>

{{ $notifications->links() }}

The only additional thing to note here is that we dynamically include the table row using blade @includeIf depending on the type of notification. That way when we come to add more notification types later down the line, it will be a case of making the easy change.

We now have a paginated table of notifications. The only issue is this will behave exactly as per a standard blade view. Clicking a pagination link will still cause a full page to reload. We're not really harnessing the full power of Livewire at this point.

To make the pagination dynamic using Livewire, we just have to make one simple change.

class Notifications
{
    use WithPagination;
    …
}

That's it! All we have to do is use the WithPagination trait.

Intrigued by how Livewire does this, I went on a little source dive and found the answer is elegantly simple.

The WithPagination trait adds some methods to the component which uses it. It also swaps out the default view used by Laravel's paginator to one that ships with Livewire (this is configurable should you need to roll your own). This view contains its own actions which call methods defined in the trait. These methods, in turn, manipulate the paginator causing the component to re-render.

<li>
    <button wire:click="gotoPage({{ $page }})">
        {{ $page }}
    </button>
</li>

Above is a snippet from Livewire's pagination view. Clicking the button in the snippet above will call the goToPage method of the component which is provided by the trait:

public function gotoPage($page)
{
    $this->paginator['page'] = $page;
}

With pagination working, the next part of the reactive interface to think about was the ability to mark a notification as read.

For this, we have a button in the interface which a user can click. Typically, this would be a form submission which, again, would come with an unwanted page reload. This can be prevented with Livewire by creating an action.

<button wire:click="markAsRead('{{ $notification->id }}')">
    Mark as read
</button>

The wire:click attribute prompts Livewire to intercept the button click and make a call to the markAsRead method in the corresponding component.

public function markAsRead(string $notificationId)
{
    $this->notificationId = $notificationId;

    $this->authorize(NotificationPolicy::MARK_AS_READ, $this->notification);

    $this->notification->markAsRead();

    $this->emit('NotificationMarkedAsRead', Auth::user()->unreadNotifications()->count());
}

You may notice, we are able to use Laravel's built-in authorization to determine whether or not the action should be allowed. Livewire will return a 403 status code should the user not be allowed to perform the action.

After marking the notification as read, the updated paginated list of notifications are returned and the interface will dynamically update accordingly.

All that was left to complete this feature was to implement the notification indicator.

This was a little more tricky, but Livewire still has us covered.

The issue was the notification indicator needed to be used in multiple parts of the interface, but outside of the component itself.

For occasions such as this, Livewire allows us to communicate between components using events.

In the code snippet above, I purposefully hid the following line from immediately before the return statement.

$this->emit('NotificationMarkedAsRead', $unreadNotifications->total());

Here, we fire an event called NotificationMarkedAsRead and provide it with the total number of unread notification which we get from the paginator instance.

Now, we can define a new Livewire component which can listen for and act on that event.

class NotificationCount extends Component
{
    public $count;

    protected $listeners = [
        'NotificationMarkedAsRead' => 'updateCount',
    ];

    …

    public function updateCount(int $count): int
    {
        return $count;
    }
}

Within the component, an array of listeners is defined with the event name as the key and the method which should be called when the event is fired as the value.

This syntax feels nicely familiar as it's pretty much the same syntax that Laravel uses for its event system.

In the updateCount method, the argument, $count passed to the listener is directly returned to the view and will, again, update dynamically with no page reload ?.

With the feature completed, below is the result.

Notifications Notifications mark one

But that's not where this story ends…

Tailwind UI

Just as I submitted the pull request, Tailwind UI was launched. Tailwind UI is a set of beautifully designed UI components produced by the creators of Tailwind CSS and the authors of Refactoring UI, Adam Wathan and Steve Schoger.

Dries and I had already talked about how we would utilise Tailwind UI for Laravel.io when it was released.

We quickly realised that the early access shipped with all of the components needed to give the notifications feature a little polish, so we decided to bite the bullet and swap them out right away.

Dries had already carried out the Tailwind UI installation and replaced some of the components on the site.

As such, all that needed to be done was to copy the relevant components directly from the Tailwind UI interface, paste them in place of the existing components, make some minor tweaks to colours and implement the variable data.

This was such a painless process. In fact, it probably only took 30 minutes. However, the extra shine makes all the difference.

Below is the final result.

Tailwind UI notifications Notifications mark two

Summary

Building this feature has been a lot of fun, I've learnt a huge amount along the way and I think the end result looks great and functions just as we wanted when fleshing out the task.

I had been looking forward to building this feature as it really lays the foundations for some of the big plans that Dries and I have for the project moving forward.

If you would like to get involved in the project, we'd be really grateful for the help, so please feel free to get in touch!

As always, if you have any questions or spot any issues, let me know.

Last updated 1 month ago.

driesvints, taftse, alejandrozepeda, wmandai, chistel, neil, euneuber, dk009dk liked this article

8
Like this article? Let the author know and give them a clap!
joedixon (Joe Dixon) Software developer @laravel, maintainer @laravelio

Other articles you might like

Article Hero Image December 13th 2024

How to add WebAuthn Passkeys To Backpack Admin Panel

Want to make your Laravel Backpack admin panel more secure with a unique login experience for your a...

Read article
Article Hero Image December 13th 2024

Quickest way to setup PHP Environment (Laravel Herd + MySql)

Setting up a local development environment can be a time taking hassle—whether it's using Docker or...

Read article
Article Hero Image December 9th 2024

Access Route Model-Bound Models with "#[RouteParameter]"

Introduction I've recently been using the new #[RouteParameter] attribute in Laravel, and I've been...

Read article

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.