shaikh saliem
shaikh saliem

Reputation: 11

How to Implement Queue with Custom Database in Multitenant Architecture?

I’m working on a multitenant application in Laravel where the database connection is determined dynamically based on the subdomain of the request. For example:

I’ve implemented middleware (IdentifyTenant) to detect the subdomain, fetch the tenant’s database details, and set the connection dynamically like this:

config([
    'database.connections.custom' => [
        'driver'    => 'mysql',
        'host'      => $tenantDetails['db_host'],
        'database'  => $tenantDetails['db_name'],
        'username'  => $tenantDetails['db_user'],
        'password'  => $tenantDetails['db_password'],
        'charset'   => 'utf8mb4',
        'collation' => 'utf8mb4_unicode_ci',
        'prefix'    => '',
        'strict'    => false,
        'engine'    => null,
    ],
]);
DB::setDefaultConnection('custom');

This works perfectly for HTTP requests, and the database connection is dynamically set based on the subdomain.


The Problem: Queue Workers

When I run php artisan queue:work or php artisan queue:listen, the queue worker always picks the default database from the .env file. However, in my case, I need the worker to dynamically determine the database connection based on the tenant details stored in the job.


What I’ve Tried:

  1. Passing Tenant Details in the Job Payload: I included tenant-specific details in the job when dispatching it:

    $tenantDetails = [
        'db_host' => '127.0.0.1',
        'db_name' => 'spain_db',
        'db_user' => 'root',
        'db_password' => '',
    ];
    YourJob::dispatch($tenantDetails);
    

    In the job’s handle method, I dynamically set the database:

    config([
        'database.connections.custom' => [
            'driver'    => 'mysql',
            'host'      => $this->tenantDetails['db_host'],
            'database'  => $this->tenantDetails['db_name'],
            'username'  => $this->tenantDetails['db_user'],
            'password'  => $this->tenantDetails['db_password'],
            'charset'   => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix'    => '',
            'strict'    => false,
            'engine'    => null,
        ],
    ]);
    DB::setDefaultConnection('custom');
    
  2. Using the JobProcessing Event: I created a listener for the JobProcessing event to dynamically configure the tenant database:

    namespace App\Listeners;
    
    use Illuminate\Queue\Events\JobProcessing;
    use Illuminate\Support\Facades\DB;
    
    class SetTenantForQueue
    {
        public function handle(JobProcessing $event)
        {
            $tenantDetails = $event->job->payload()['data']['tenantDetails'] ?? null;
    
            if ($tenantDetails) {
                config([
                    'database.connections.custom' => [
                        'driver'    => 'mysql',
                        'host'      => $tenantDetails['db_host'],
                        'database'  => $tenantDetails['db_name'],
                        'username'  => $tenantDetails['db_user'],
                        'password'  => $tenantDetails['db_password'],
                        'charset'   => 'utf8mb4',
                        'collation' => 'utf8mb4_unicode_ci',
                        'prefix'    => '',
                        'strict'    => false,
                        'engine'    => null,
                    ],
                ]);
                DB::setDefaultConnection('custom');
            }
        }
    }
    

    And registered it in EventServiceProvider:

    use Illuminate\Queue\Events\JobProcessing;
    use App\Listeners\SetTenantForQueue;
    
    protected $listen = [
        JobProcessing::class => [
            SetTenantForQueue::class,
        ],
    ];
    

The Issue:

Despite these attempts, the worker still seems to pick the default database from the .env file when executing the job. How can I ensure that the queue worker dynamically uses the tenant database based on the subdomain or the job payload?


What I Need Help With:

  1. How can I configure Laravel’s queue worker to dynamically set the database connection for each job?
  2. Is there a better way to handle multitenant databases with queues in Laravel?
  3. How can I make the queue worker "tenant-aware" without requiring manual intervention (e.g., running separate workers for each tenant)?

I’d really appreciate any advice or best practices for handling this scenario in a multitenant architecture. Thanks in advance!

Upvotes: 0

Views: 67

Answers (1)

Bolaji
Bolaji

Reputation: 81

I just thought of a quick and dirty way that you can use to achieve this. I would create an abstract Base class like so;

abstract class BaseTenancyJob implements ShouldQueue
{
  use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

  
  public function __construct(public ?int $tenancyId = null)
  {
     $this->initialize();
  }


  public function initialize(): void
  {
     //this could have been set prior at the initial instantiation of the app
     //when the app still had context because I would assume if a job gets fired
     //it would mostly be when there was context but in case there was no context
     //the tenancyId would then be passed in like the example you gave.
    $this->tenancyId = $this->tenancyId ?? config('app.tenancy_id');
  }


  public function middleware(): array
  {
      return [
        new PreserveTenancyConfig($this->tenancyId),
      ];
  }
}

Then I'll create a middleware that runs before the job is processed. This middleware is what is passed as part of the middleware in the BaseJob class. What it will do is to set the tenancy config. In cases like this, I feel database is just one of many things that could be needed, so that method should take care of everything including database change.

class PreserveTenancyConfig
{

 public function __construct(public ?int $tenancyId = null) {}

/**
 * Handle the job and preserve the config context.
 *
 * @param  mixed  $job
 * @param  callable  $next
 * @return void
 */
public function handle(mixed $job, callable $next): void
{
    if (! is_null($this->tenancyId)) {
        //do some query with the Id to resolve tenancy
        $tenancy = Tenancy::find($this->tenancyId);
        
        //TenancyService facade
        //I've put the suggestion of a facade here as you may need
        //to do a lot like get config, set config in different places
        //you don't want to bother about instantiating the service class
        //from time to time.
        \App\Tenancy\Tenancy::setTenancyToConfig($tenancy);
    }
    $next($job);
}

The setTenancyToConfig() method could look like this;

/**
 * @param  Tenancy $tenancy
 * @return void
 */
public function setTenancyToConfig(Tenancy $tenancy): void
{
    config()->set('app.tenancy_id', $tenancy->id);
    config()->set('app.name', $tenancy->name);
    config()->set('database.connections.'.$tenancy->database, $tenancy->database_connection);
    config()->set('database.default', $tenancy->database);
}

Now the actual job could look like so;

class TestJob extends BaseTenancyJob
{
  /**
   * Create a new job instance.
   *
   * @param  string  $someValue
   * @param  int|null  $tenancyId
   */
   public function __construct(public string $someValue, public ?int $tenancyId = null)
   {
      parent::__construct($this->tenancyId);
   }

  /**
   * Execute the job.
   *
   * @return void
   */
  public function handle(): void
  {
    //if everything works out this should dump the tenancyId you have set
    //it would also mean everything you have set will now take precedence
    dump(config('app.tenancy_id'));
    dump($this->someValue);
    dump(User::count()); // A way to track data change as DB would have changed.
  }
}

I don't particularly like this solution but it should solve your problem.

Modifying your example the way it's currently without any big modification based on the suggestion I've here would look like this;

class YourJob extends BaseTenantJob
{
  /**
   * Create a new job instance.
   */
   public function __construct(private array $tenantDetails)
   {
      parent::__construct($this->tenantDetails);
   }

  /**
   * Execute the job.
   *
   * @return void
   */
  public function handle(): void
  {
     //Do whatever in your job
  }
}


abstract class BaseTenantJob implements ShouldQueue
{
  use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

  
  public function __construct(private array $tenantDetails)
  {
  }


  public function middleware(): array
  {
      return [
        new PreserveTenantConfig($this->tenantDetails),
      ];
  }
}

class PreserveTenantConfig
{

 public function __construct(private array $tenantDetails) {}

/**
 * Handle the job and preserve the config context.
 *
 * @param  mixed  $job
 * @param  callable  $next
 * @return void
 */
public function handle(mixed $job, callable $next): void
{
        config()->set('database.connections.'.$tenantDetails['database'], $tenantDetails);
config()->set('database.default', $tenantDetails['database']);

    $next($job);
}

NB: I've made some assumptions with tenantDetails, adjust accordingly and this will definitely work.

Upvotes: 0

Related Questions