Event-Driven Development in Laravel

I recently started a fresh project with my people at WTS. A SaaS themed solution for content creators to engage with their fans. (Oh by the way, we've put out new music: Lofn 3, a collection of love stories. Go check it out).

Landing Page Mockup For TrivYeah

As with all things new, A SaaS application usually starts out with simple functionality, without intertwined logic and complex calculations.  User Logs in, User creates A, C happens. User updates B, D is calculated. User logs out. etc. But as the user base grows, or even as active development continues, new business requirements come up that entirely threatens the structure of existing logic. If the codebase is not rightly structured, it is likely to drift into a plethora of issues. Longer functions, longer constructor parameters, nested ifs within nested ifs within nested loops.

That's how legacy code bases are born. New developers are afraid to touch the code, it takes days to understand what is going on, a lot of logic all happening in one place.

Having complex domain logic cannot be avoided when building SaaS applications, however the right approach must be taken to keep the codebase understandable, maintainable and structured. A good way to begin would be to adopt the OOP principles, having classes, interfaces and objects which are SOLID will provide a basic structure for the application of business requirement.

What about events? How does it come into play here?

The cost of increasing changes

Let's take the TrivYeah app as an instance. The application is a multi tenant system, that is, each registered organization on the app will have its own database, pointing to its own hostname.

<?php

namespace System\Services;

use System\Models\User;
use System\Models\Hostname;
use System\Models\Organization;

class SystemService
{

    /**
     * Create a new Tenant
     * @param array
     * 
     * @return Tenancy\Identification\Contracts\Tenant
     */
    public function createTenant(array $tenantInformation)
    {
        return DB::transaction(function () use ($tenantInformation) {
        
            $tenant = $this->createOrganization($tenantInformation)
    
            $hostName = $this->createHostName($tenant);
            
            if ($tenant->isPremiumUser()) {
            
            	$this->setUpTenantDatabase($tenant, $hostName);
            }

            return $tenant;
        });
    }
}
A System Service Object

Here we see that a couple of things are going on in the createTenant method, we are creating:

  • An Organization
  • A hostname for the organization
  • A database for the organization

At the surface, everything seems good. Not much is going on, just basic stuff. Now after a couple of days, we decide that we want to add some default configuration settings for newly created organizations. We can easily go back to our method and add a few lines of code.

<?php

namespace System\Services;

use System\Models\User;
use System\Models\Hostname;
use System\Models\Organization;

class SystemService
{

    /**
     * Create a new Tenant
     * @param array
     * 
     * @return Tenancy\Identification\Contracts\Tenant
     */
    public function createTenant(array $tenantInformation)
    {
        return DB::transaction(function () use ($tenantInformation) {
        
            $tenant = $this->createOrganization($tenantInformation)
    
            $hostName = $this->createHostName($tenant);
            
            if ($tenant->isPremiumUser()) {
            
            	$this->setUpTenantDatabase($tenant, $hostName);
                
                //create default settings here
                $this->createDefaultSettings($tenant);
            }

            return $tenant;
        });
    }
}
Add default settings to the tenant creation process

There. Done. Nobody died. Nothing difficult. Right?

Well, two more days later, business requirement changes and now we need to create an admin user in the tenant database as soon as the database is created. That's right we'll just go back to that method and add a few more lines.

<?php

namespace System\Services;

use System\Models\User;
use System\Models\Hostname;
use System\Models\Organization;

class SystemService
{

    /**
     * Create a new Tenant
     * @param array
     * 
     * @return Tenancy\Identification\Contracts\Tenant
     */
    public function createTenant(array $tenantInformation)
    {
        return DB::transaction(function () use ($tenantInformation) {
        
            $tenant = $this->createOrganization($tenantInformation)
    
            $hostName = $this->createHostName($tenant);
            
            if ($tenant->isPremiumUser()) {
            
            	$this->setUpTenantDatabase($tenant, $hostName);
                
                //create default settings here
                $this->createDefaultSettings($tenant);
                
                //create admin user
                $this->createAdminUser($tenant);
            }

            return $tenant;
        });
    }
}
Add Create Admin Logic to the createTenant method

We can both see where this is going, what happens when business requirements keep changing/growing? What if we need to send out an immediate welcome email, or create a default account ledger for the organization?

It most often would lead to an inflated code base, filled with different logic, that can become more complex and hard for new developers who would be working on the project to quickly understand.

Decoupling logic using events

Events depict that an activity is taking place. We can make use of events to build modularized applications. The more modular our application is, the more maintainable it will be.

Think of events as a kind of interface that allows interested parties to plug into an activity. Let's go back to our example method above.

We can rewrite our createTenant function to make use of events to achieve its goal.

<?php

namespace System\Services;

use System\Models\User;
use System\Models\Hostname;
use System\Models\Organization;

class SystemService
{

    /**
     * Create a new Tenant
     * @param array
     * 
     * @return Tenancy\Identification\Contracts\Tenant
     */
    public function createTenant(array $tenantInformation)
    {
        return DB::transaction(function () use ($tenantInformation) {
        
            $tenant = $this->createOrganization($tenantInformation)
    
            event(new TenantCreated($tenant, $tenantInformation));

            return $tenant;
        });
    }
}
Our createTenant method triggering an event

Here, the only thing we do in our method is to actually create the tenant (which is the organization). By triggering the TenantCreated event, we are telling other interested parties that the Tenant has been created, then we return the newly created tenant.

class TenantCreated
{
    
    public $tenantInformation;
    
    public $hostName = null;
    
    public $tenant;
    
    public function __construct(
    	Tenant $tenant, array $tenantInformation
    )
    {
    	$this->tenant = $tenant;
        $this->tenantInformation = $tenantInformation;
    }
    
    public function setHostName(HostName $hostName)
    {
    	$this->hostName = $hostName;
    }
}
The TenantCreated Event Class

Now that we have triggered our event, we can now create several handlers/listeners that will act on this event.

class CreateHostName
{
	/**
    * @var System\Services\SystemService
    */
	protected $service;
    
    public function __construct(SystemService $service)
    {
    	$this->service = $service;
    }
    
    public function __handle(TenantCreated $event)
    {
    	$hostName = $this->service->createHostName($event->tenant);
        
        $event->setHostName($hostName);
    }
}
Create Host Name Listener
class SetupDatabase
{
	/**
    * @var System\Services\SystemService
    */
	protected $service;
    
    public function __construct(SystemService $service)
    {
    	$this->service = $service;
    }
    
    public function __handle(TenantCreated $event)
    {
    	$this->service->setUpDatabase(
        	$event->tenant, $event->hostName
        );
        
        $this->service->createDefaultSettings($event->tenant);
    }
}
Setup Tenant Database Listener
class CreateAdminUser
{
	/**
    * @var System\Services\SystemService
    */
	protected $service;
    
    public function __construct(SystemService $service)
    {
    	$this->service = $service;
    }
    
    public function __handle(TenantCreated $event)
    {
        $this->service->createAdminUser(
        	$event->tenant, $event->tenantInformation
        );
    }
}
Create Admin User Listener

We can see how we have made use of events to decouple our code. When the TenantCreated event is triggered, Laravel passes the event object to all the registered listeners of that event. First the CreateHostName listener is called, followed by the SetupDatabase listener, followed by the CreateAdmin listener. Each listener gets called in the order you define them.

If we have a new requirement which needs to be done when creating our tenant, we do not need to touch the createTenantMethod. All we need to do is write a listener to handle the event.

class SendWelcomeEmail
{
    public function __handle(TenantCreated $event)
    {
        $event->tenant->notify(
        	new System\Notifications\WelcomeToTrivYeah
        );
    }
}
SendWelcomeEmail Listener

Events beyond modularization

Apart from helping with decoupling code, we can make use of events to trigger application level changes like, dynamically registering route files, appending middlewares, or even setting a new route action.

class IdentifyTenantServiceProvider extends ServiceProvider
{
	public function register()
    {
    	$this->app->bind("tenancy", function ($app) {
        	return new System\Tenant\Tenancy($app);
        });
    }
    
	public function boot()
    {
    	$this->app->resolve("tenancy")->identifyTenant()
    }
}
IdentifyTenantServiceProvider

Here we have a service provider, that identifies a tenant by the environment. We can let other aspects of our application know when a tenant has been identified by triggering an event.

class Tenancy
{
	//... other random magic
    
	public function identifyTenant()
    {
    	$tenant = $this->app->runningInConsole() ? 
                $this->identifyByConsole() : 
                $this->identifyByHttp();
                
         return $this->dispatchTenantIdentifiedEvent($tenant);
    }
    
    public function dispatchTenantIdentifiedEvent($tenant = null)
    {
    	$event = $tenant == null ? new NothingIdentified :
        		new TenantIdentified($tenant);
        
        event($event);
    }
}
System\Tenant\Tenancy class

We are dispatching two types of event, depending on the outcome of resolving the tenant. If a tenant is resolved, we trigger the TenantIdentified event, if not, we trigger the NothingIdentified event.

class TenantIdentified
{
	public $tenant;
    
    public function __construct(Tenant $tenant)
    {
    	$this->tenant = $tenant;
    }
}
The TenantIdentified Event

This gives us the ability to plug into any of the scenarios. In our case, we want to load a route file based on the tenant.

class LoadTenantRouteFile
{
	protected $service;
    
    protected $router;
    
    protected $defaultTenantRouteFile = "tenant/routes/api.php";
    
    public function __construct(TenantService $service, Router $router)
    {
    	$this->service = $service;
        $this->router = $router;
    }
    
    public function __handle(TenantIdentified $event)
    {
    	$routeFile = $this->service->getRouteFileForTenant(
        	$event->tenant
        ) ?? $this->defaultTenantRouteFile;
        
        $this->flush()->setRouteFile($routeFile);
        
    }
    
    protected function flush($router)
    {
    	$this->router->setRoutes(new RouteCollection());
        
        return $this;
    }
    
    protected function setRouteFile($router, $routeFile)
    {
    	$this->router->group(
        	['middleware' => 'api'], base_path($routeFile)
        );
    }
}
LoadTenantRouteFile Listener

A few things are going on here. First, we get the tenant route path based on the organization, if the tenant has a route configured, we save it in memory, else we use the default tenant route path. We then get the router instance from Laravel's container, flush the existing route collection, and set the new path for the tenant on the router. Done.  Dazzzall.

One more reference

In Italo Baeza's recently released package, Laraguard, we see another example of how events are used to manipulate functionality.

This package silently enables two factor authentication using 6 digits codes, without Internet or external providers.
    /**
     * Register a listeners to tackle authentication.
     *
     * @param  \Illuminate\Contracts\Config\Repository  $config
     * @param  \Illuminate\Contracts\Events\Dispatcher  $dispatcher
     */
    protected function registerListener(
    	Repository $config, Dispatcher $dispatcher
    )
    {
        //Some magic happened here!
        
        $dispatcher->listen(Validated::class,
            'DarkGhostHunter\Laraguard\Listeners\EnforceTwoFactorAuth@checkTwoFactor'
        );
    }
LaraguardServiceProvider.php

A listener is hooked into the Illuminate\Auth\Events\Validated event, which gets fired after a login attempt is validated. When this happens, the package plays out its own validation logic.

<?php

namespace DarkGhostHunter\Laraguard\Listeners;

use Illuminate\Http\Request;
use Illuminate\Auth\Events\Validated;
use Illuminate\Auth\Events\Attempting;
use Illuminate\Contracts\Config\Repository;
use DarkGhostHunter\Laraguard\Contracts\TwoFactorAuthenticatable;

class EnforceTwoFactorAuth
{
	//Some magic happened here

    /**
     * Checks if the user should use Two Factor Auth.
     *
     * @param  \Illuminate\Auth\Events\Validated  $event
     * @return void
     */
    public function checkTwoFactor(Validated $event)
    {
        if ($this->shouldUseTwoFactorAuth($event->user)) {

            if ($this->isSafeDevice($event->user) 
            || ($this->hasCode() && $invalid = 
            $this->hasValidCode($event->user))) {
                return $this->addSafeDevice($event->user);
            }

            $this->throwResponse($event->user, isset($invalid));
        }
    }
}
EnforceTwoFactorAuth.php

Conclusion

We've been able to explore ways how events can help us build more structured apps. It may sound like it's more work. The tempting feeling of :

"why write more classes? when you can just do everything in one place?"

It shouldn't be about what you can do for the code, or the necessity of agile sprints. If your code could speak, what would it say about you?

For more references about events in Laravel, The official documentation is splendid:

https://laravel.com/docs/master/events

If you liked this piece, send a holla on twitter @gabrielnwogu

**thanks to etinosa obaseki for the edits.