Adding tenancy-awareness to Laravel queues and artisan commands

Multi-tenant applications are a great way of offering a customised experience for multiple users, from a single, shared codebase. There are a number of different ways through which a "tenant mode" can be accessed. Some of the more common ways are via a custom domain or subdomain, a URL parameter, or a value in the user session. When the application boots, it checks for this value, and then configures the application internally - loading the correct database, applying the correct assets, and so on. This all works fine when we've got these values coming in via requests to our application, but how do we handle tenancy in parts of the application where there is no domain, URL parameter or user session?

Queues

Laravel has a great queue implementation. These are processes which run continually in the background, handling either more intensive or less time-critical jobs. The challenge here is that queues are not invoked through a HTTP request, so don't have any of the tenancy indicators we mentioned above.

Can we have a queue per tenant? One option is to have a dedicated queue for each tenant. php artisan queue:listen --tenant=tenant_a

A challenge here is that it will lead to a lot of queue runners to manage on a production environment. It also needs a new queue runner to be started for each new tenant added, which may be a challenge to guarantee on production.

Another approach is to make queued jobs tenancy-aware. In our multi-tenant application, we need a way to make sure our application is tenant-aware. Let's say we have a TenancyProvider, which has been registered in the providers array in config/app.php:

'providers' => [
   ...
   App\Providers\TenancyProvider::class,
],

Within our boot function, we're going to attempt configuration for each of our environments.

public function boot()
{
    if ($this->app->runningInConsole()) {
        $this->configureConsole();
        $this->configureQueues();
    } else {
        // detectTenancy is where we're identifying the tenant based
        // on domain, URL param, session, etc
        $this->setEnvironmentForTenant($this->detectTenancy());
    }
}

Inside our TenancyProvider, we will likely have a lot of logic already to detect which tenancy we're running under via URL/user session, and set the environment accordingly. We're now going to be calling this logic from multiple contexts (HTTP request, queues, and console), so we can bundle it up in it's own function.

private function setEnvironmentForTenant($tenant)
{
    // Set database for tenant
    // Set cache for tenant
    ...
    // Stamp the tenant name on each queued job.
    Queue::createPayloadUsing(function () use ($tenant) {
        return ['tenant' => $tenant];
    });
}

A key addition here is the createPayloadUsing() function. After invoking this, whenever a queued job is created in the application, the current tenant will be added as an attribute to the queue job. When we are running in console mode, we can set up our queue listener to check for this parameter, and set the environment accordingly:

private function configureQueues()
{
    // When a job is processed, check for tenant name to
    // set correct environment.
    $this->app['events']->listen(\Illuminate\Queue\Events\JobProcessing::class, function ($event) {
        if (isset($event->job->payload()['tenant'])) {
            $this->setEnvironmentForTenant($event->job->payload()['tenant']);
        }
    });
}

Long-running process

A critical thing to note about queues is the difference in behaviour, depending on how they're invoked. There are a couple of common ways to invoke queue handlers:

  • queue:listen: The application is restarted on every invocation. Any tenancy changes will be applied on boot. Code changes are taken into consideration on the next run.
  • queue:work: This is a long-running job, where the application environment is booted, and new jobs loaded into it. Code changes aren't taken into consideration until the queue listener is restarted.

The queue:work approach is more efficient in terms of memory usage, as the application is only invoked once. However, if this is the way your queues are running, beware that swapping between tenancies may require a little extra work. Because of the way Laravel will bind services on application boot, in many cases it's not sufficient to alter a config file and then have the service work on the new value. The database is a common example of this. If a queue listener starts to work, loads tenancy A for the first job, then loads a job for tenancy B next, the database will still be on the connection for tenancy A.

For example, if you're using a connections.tenant as the main database connection, and updating the database name per tenant, then there are additional steps beyond just updating the config:

config(['database.connections.tenant.database' => 'tenant_b']);
DB::purge('tenant');
DB::reconnect('tenant');

The risk here is that you're processing a job for one tenant, but in a different tenant's environment. This can manifest as sending the wrong email template out for a receipt, for example. For instances which are resolved on first usage, it's possible as part of the tenancy boot to force Laravel to forget about the currently-resolved instance. This will mean that when we try to use something like a Mail facade, we force it to resolve under the hood, but now it will resolve to our current tenant configuration.

private function setEnvironmentForTenant($tenant)
{
    ...
    // Clear current mail bindings, so next usage will re-bind them
    Mail::clearResolvedInstances();
    App::forgetInstance('mail.manager');
}

This is also a concern if there are singletons being bound with tenant-specific settings, or singletons which latch on to the database connection at the time of their creation. Typically these will need to be re-bound, using something like the below:

$this->app->singleton('square1\ExcitingPackage\SettingsManager', function ($app) {
    return new \square1\ExcitingPackage\SettingsManager($app);
});

This re-binding can also impact testing, if you're relying heavily on Laravel's mocks, and built-in fakes. For example, your test may create a Notification::fake to emulate a particular behaviour for your test, but if the setEnvironmentForTenant call is triggered afterwards, and you're re-binding the Notification facade, it can mean that your fake gets lost. In this scenario, conditionally rebinding based on test mode can help to avoid this problem.

Artisan commands

When running an artisan command, we will often want to run a command in a tenant-specific environment. The simplest way to do this is to add a --tenant=X parameter to each artisan command. Rather than doing this on individual commands, we can enable this method globally.

Coming back to our TenancyProvider, we can set this up when we are configuring our console mode:

private function configureConsole()
{
    // Add --tenant option to any artisan command.
    $this->app['events']->listen(ArtisanStarting::class, function ($event) {
        $definition = $event->artisan->getDefinition();
        $definition->addOption(
            new InputOption('--tenant', null, InputOption::VALUE_OPTIONAL, 'The tenant subdomain the command should be run for. Use * or all for every tenant.')
        );
        $event->artisan->setDefinition($definition);
        $event->artisan->setDispatcher($this->getConsoleDispatcher());
    });
    ....
}

protected function getConsoleDispatcher() : \Symfony\Component\EventDispatcher\EventDispatcher
{
    // Hook into the app's console dispatcher event
    if (!$this->consoleDispatcher) {
        $this->consoleDispatcher = app(EventDispatcher::class);
    }

    return $this->consoleDispatcher;
}

The key bit here is the InputOption('--tenant'..) line. This is how we add an optional argument to artisan commands. Doing it in this way means that we are binding it to every artisan command in the application. Setting it as InputOption::VALUE_OPTIONAL means that it isn't compulsory - commands which don't need a tenant to run will not be broken by this change.

Now that we're adding a tenant identifier to each command, we need to close the loop, by having that command run under the tenant mentioned in the command argument. In the same way that we've automatically enabled all of our artisan commands to accept this parameter, we can also automatically apply it.

private function configureConsole()
{
    ...
    // Check for tenant option on artisan command, setting environment if it exists.
    $this->getConsoleDispatcher()->addListener(ConsoleEvents::COMMAND, function (ConsoleCommandEvent $event) {
        $tenant = $event->getInput()->getParameterOption('--tenant', null);

        if (!is_null($tenant)) {
            $this->setEnvironmentForTenant($tenant);
        }
    });
}

This block will add a listener to each console command, checking for the --tenant option, and, if found, will apply the environment we need for that tenant.


PHPers Summit 2024 Speaker

International PHP Conference
Munich, November 2024

In November 2024, I'll be giving a talk at the International PHP Conference in Munich, Germany. I'll be talking about the page speed quick wins available for backend developers, along with the challenges of policing dangerous drivers, the impact of TV graphics on web design, and the times when it might be better to send your dev team snowboarding for 6 months instead of writing code!

Get your ticket now and I'll see you there!


Share This Article

Related Articles


Lazy loading background images to improve load time performance

Lazy loading of images helps to radically speed up initial page load. Rich site designs often call for background images, which can't be lazily loaded in the same way. How can we keep our designs, while optimising for a fast initial load?

Idempotency - what is it, and how can it help our Laravel APIs?

Idempotency is a critical concept to be aware of when building robust APIs, and is baked into the SDKs of companies like Stripe, Paypal, Shopify, and Amazon. But what exactly is idempotency? And how can we easily add support for it to our Laravel APIs?

Calculating rolling averages with Laravel Collections

Rolling averages are perfect for smoothing out time-series data, helping you to gain insight from noisy graphs and tables. This new package adds first-class support to Laravel Collections for rolling average calculation.

More