Event-driven Laravel Applications

Service Appui Interne – Application Services 2018–09-xx

1. Introduction to events

1.1. Definition

An event-driven application is an application that largely responds to actions generated by the user or the application. The application then executes other code in response to the actions.

action = events

1.2. Why?

Make your code really easier by keeping it as concise as possible. An event-driven app will be, a.o.t.:

  1. easier to maintain,
  2. less risk of regression,
  3. make classes responsible for only one thing,
  4. make team’s work easier,
  5. expects the unexpected,
  6. allow asynchronous tasks easily (queues in Laravel).
  7. be ready for broadcasting

1.2.1. Easier to maintain

By calling an event like NewUserCreated, we will no more have a very big function where we’ll store the user in the database, send a welcome email, add the user to a newsletter, dispatch user’s information’s to others apps, ...

We’ll just do one thing (the S of SOLID): store the user in the database i.e. the core feature of creating a new user. Then, second and last thing, throw a message Hey!, a new user has been added, do you need to make things?.

1.2.2. Less risk of regression

Since features will be put into smaller pieces (our event’s listeners), the need to modify our core files will be less since our real functionality will be in our listeners.

Less changes in our core files implies less risks of regression

🐛

1.2.3. Make classes responsible for only one thing

A class should do only one thing like creating a new user in the database.

Actions like sending notification email, adding the user to a newsletter, send a SMS, ... are several things and are good candidates for events.

Since our core code only concentrate to essential things, we can here too easily respect that principle.

THIS IS SOOOOOO BAD 😱

Route::get('signup', function() {
    // create the new user
    $user = User::create($userData);

    // a user is created, do things!

    // send the user a confirmation email

    // set up that users account

    // email them introductory information

    // and much more stuff
});

1.2.4. Make team’s work easier

Since a lot of code is done within event’s listener and not in the core of the program, implementing a new feature, updating code, ... is done in smaller part: the event’s listener.

When several people are working on the same project, the risk of conflicts becomes bigger when two people change the same file. This won’t be the case when features are put in dedicated files: event’s listener.

A bug in the Send notification mail feature impacts only that listener and not a bigger helper.

1.2.5. Expects the unexpected

The code will also be ready for the future: need a new feature? In most cases, the solution will be, simply, 'add a new event’s listener’.

For instance, months after the release of the program, when a new user is added, we now need to send an email to the logistic team for things like badges, small office material, ...: easy! One line to change in our existing code and one new file to create.

Of course, the benefit is the same if we need to remove a feature: no more need to send a notification email? Just remove only one line (the reference to the listener) and that’s all!

🎆 🎊 🎉 🎺

1.2.6. allow asynchronous tasks easily

With Laravel’s queues, it’s possible to defer the execution of an action f.i. sending a notification by email.

Events’s listeners can be put in a queue so the execution of the core program will not be blocked, this will increase the responsiveness of the program and give better user experience.

1.2.7. be ready for broadcasting

https://laracasts.com/series/get-real-with-laravel-echo

Since an event can be broadcasted, third parties software (like pusher.com) can be used: these interfaces will listen our events and dispatch them back to listeners.

We could have a web page that update a record (event OrderUpdated f.i.) and, no matter where in the world, have another web page that would be notified of that change.

For example, we could create a new job description at federal level and allow Regions to have a web application that would be notified of the new job.

This even though there would be no technical links between the applications (no data exchange, no database links, ...) except the broadcast one (send an event for one, listen to the event for others)

1.3. So, things to keep in memory 🙋

  1. We can have more than one listener by events (we just need to order them (which listener will be fired the first, the second, ...),
  2. We can have as many events as we want so, really, don’t hesitate to add events even if, today, you don’t need them. Make your code as opened as possible.

Firing an event does nothing. If nobody listen, nothing will be done.

2. Examples

2.1. Without event-driven approach

public function register(Request $request)
{
    // validate input
    $this->validate($request->all(), [
        // [...]
    ]);

    // create user and persist in database
    $user = $this->create($request->all());

    // send welcome email
    Mail::to($user)->send(new WelcomeToSiteName($user));

    // send SMS
    Nexmo::message()->send([
        // [...]
    ]);

    // Sign user up for weekly newsletter
    Newsletter::subscribe($user->email, [
        // [...]
    ], 'Mail subject');

    // login newly registered user
    $this->guard()->login($user);

    return redirect('/home');
}

🙀 There are many problems, among others:

2.2. With event-driven approach

public function register(Request $request)
{
    // validate input
    $this->validate($request->all(), [
        // [...]
    ]);

    // create user and persist in database
    $user = $this->create($request->all());

    // fire event once user has been created
    event(new UserRegistered($user));

    // login newly registered user
    $this->guard()->login($user);

    return redirect('/home');
}

😺 Much better!:

3. Laravel implementation

Events implementation is made of two things :

3.1. Location within Laravel

There are two folders that will handle events in Laravel:

3.2. Create them with Artisan

php artisan make:event UserRegistered
php artisan make:listener SendWelcomeMail --event=UserRegistered
php artisan make:listener SignupForWeeklyNewsletter --event=UserRegistered
php artisan make:listener SendSMS --event=UserRegistered

3.3. EventServiceProvider

protected $listen = [
    'App\Events\UserRegistered' => [
        'App\Listeners\SendWelcomeMail',
        'App\Listeners\SignupForWeeklyNewsletter',
        'App\Listeners\SendSMS',
    ],
];

3.4. Dispatch an event

use App\Events\UserRegistered;

public function register(Request $request)
{
    // [...]

    // create user and persist in database
    $user = $this->create($request->all());

    // fire event once user has been created
    event(new UserRegistered($user));

    // [...]
}

3.5. Stopping The Propagation Of An Event

Sometimes, you may wish to stop the propagation of an event to other listeners. You may do so by returning false from your listener’s handle method.

class SignupForWeeklyNewsletter
{
    public function handle(User $user)
    {
        // Do things. Return false if the event should not
        // be processed furthermore by others listeners
        return false;
    }
}

3.6. Queuing events

With Laravel, it’s possible to put events in a queue so the user won’t need to wait that every actions have been processed by the server. For instance, sending a notification email doesn’t need to be done immediately but can be done when the user’s action is done.

3.7. Native events

With his Eloquent model, Laravel will automatically fire created, saved, updated and deleted events respectively.

4. Real example

Read a markdown file and replace tags by content.

The concept is to be able to read a .md file on the disk, that file can (or not) contains variables that needs to be replaced by a content.

For instance, we can have a last_updated variable that will be replaced by the last modification date of the .md file, TOC with a dynamic table of content, footer with a dynamic footer and so on.

🎓 The main program shouldn’t be impacted at all: zero, one or many listeners? He’ll just fire the event and will continue his execution.

So, extending our markdown syntax by creating new tags won’t have an impact to our code base and, that aspect is, a huge benefit. 🎓

4.1. The markdown file

Our content with three variables (tags).

# Event-drive app

%LAST_UPDATE%

%TOC%

%FOOTER%

4.2. Create our event

By reading a .md file we’ll fire an event so listener can be notified and do things if needed:

php artisan make:event MarkdownGetContent

Laravel will create for us the file app/Events/MarkdownGetContent.php

We’ll edit MarkdownGetContent.php like this:

namespace App\Events;

class MarkdownGetContent
{
    protected $markdown = '';

    public function __construct(string $markdown)
    {
        return self::setMarkdown($markdown);
    }

    public function getMarkdown() : string
    {
        return $this->markdown;
    }

    public function setMarkdown(string $markdown) : bool
    {
        $this->markdown = $markdown;

        return true;
    }
}

4.3. Define our three listeners

Three variables so let’s create three listeners:

php artisan make:listener LastUpdate --event="MarkdownGetContent"
php artisan make:listener TOC --event="MarkdownGetContent"
php artisan make:listener Footer --event="MarkdownGetContent"

Laravel will create:

4.4. LastUpdate listener

Aim: replace %LAST_UPDATE% with the current date/time

namespace App\Listeners;

use App\Events\MarkdownGetContent;
use Carbon\Carbon;

class LastUpdate
{
    public function handle(MarkdownGetContent $event): boolean
    {
        $value = $event->getMarkdown();

        if (strpos($value, '%LAST_UPDATE%') !== false) {
            Carbon::setLocale(config('app.locale'));
            $dte = Carbon::now()->format('l j F Y H:i:s');
            $value = str_replace('%LAST_UPDATE%', $dte, $value);
            $event->setMarkdown($value);
        }

        return true;
    }
}

4.5. TOC listener

Aim: replace %TOC% with a table of content:

namespace App\Listeners;

use App\Events\MarkdownGetContent;

class TOC
{
    public function handle(MarkdownGetContent $event)
    {
        $value = $event->getMarkdown();

        if (strpos($value, '%TOC%') !== false) {
            $toc = "## Chapter 1\n\n### 1.1 Intro\n\n### 1.2 Usage";
            $value = str_replace('%TOC%', $toc, $value);
            $event->setMarkdown($value);
        }

        return true;
    }
}

Aim: replace %FOOTER% with a dynamic sentence:

namespace App\Listeners;

use App\Events\MarkdownGetContent;

class Footer
{
    public function handle(MarkdownGetContent $event)
    {
        $value = $event->getMarkdown();

        if (strpos($value, '%FOOTER%') !== false) {
            $footer = '© 2018-'.date('Y');
            $value = str_replace('%FOOTER%', $footer, $value);
            $event->setMarkdown($value);
        }

        return true;
    }
}

4.7. Registering the listeners

Edit app/Providers/EventServiceProvider.php and add a listener for the event:

protected $listen = [
    'App\Events\MarkdownGetContent' => [
        'App\Listeners\LastUpdate',
        'App\Listeners\TOC',
        'App\Listeners\Footer'
        ]
    ]
];

4.8. Raise the event

To keep things simple, just edit routes/web.php and change the route like this:

use App\Events\MarkdownGetContent;
use GrahamCampbell\Markdown\Facades\Markdown;

Route::get('/', function () {
    $markdown = file_get_contents(resource_path('sample.md'));

    $event = new MarkdownGetContent($markdown);
    event($event);

    $html = Markdown::convertToHtml($event->getMarkdown());

    return view('welcome', compact('html'));
});

4.9. Our view

Edit resources/views/welcome.blade.php and replace the body content like this:

<body>
    <div class="flex-center position-ref full-height">
        <div class="content">
            {!! $html !!}
        </div>
    </div>
</body>

{!! $html !!} and not {{ $html }} because our string will contains html tags

4.10. Result

Open http://127.0.0.1:8000/index.php and ...

Final

4.11. Extended event

We can of course initialize our event with more than one info and make it a real class.

With only the markdown string, our listeners won’t be able to retrieve the source filename...

Route::get('/', function () {
    $markdown = file_get_contents(resource_path('sample.md'));

    $event = new MarkdownGetContent($markdown);
    event($event);

    // [...]
});

Markdown string and metadata (use an array so we can have as many items we want):

Route::get('/', function () {
    $filename = resource_path('sample.md');

    $markdown = file_get_contents($filename);

    $meta = [];
    $meta['filename'] = $filename;
    $event = new MarkdownGetContent($markdown, $meta);
    event($event);

    // [...]
});

And the event declaration:

class MarkdownGetContent
{
    protected $markdown = '';
    protected $metadata = '';

    public function __construct(string $markdown, array $metadata = [])
    {
        $this->metadata = $metadata;

        return self::setMarkdown($markdown);
    }

    public function getMarkdown() : string
    {
        return $this->markdown;
    }

    public function getFileName() : string
    {
        return $this->metadata['filename'] ?? '';
    }

    public function setMarkdown(string $markdown) : bool
    {
        $this->markdown = $markdown;

        return true;
    }
}

});

LastUpdate event can now retrieve the file’s last modification date/time:

namespace App\Listeners;

use App\Events\MarkdownGetContent;
use File;
use DateTime;

class LastUpdate
{
    public function handle(MarkdownGetContent $event)
    {
        $value = $event->getMarkdown();

        if (strpos($value, '%LAST_UPDATE%') !== false) {
            $filename = $event->getFileName();
            $time = File::lastModified($filename);
            $dte = DateTime::createFromFormat('U', $time)->format('Y-m-d H:i:s');
            $value = str_replace('%LAST_UPDATE%', $dte, $value);
            $event->setMarkdown($value);
        }

        return true;
    }
}

5. Read more