Skip to main content

Using ReactPHP to run Drupal tasks

Published on Saturday 6, July 2019
ReactPHP code

ReactPHP is an event-driven non-blocking PHP framework that allows to you work in a long-running script through an event loop. At its core, ReactPHP provides an event loop and utilities to trigger events at specific intervals and run your code. This is different than normal PHP script execution which of a short lifecycle and per individual requests. 

ReactPHP has been used to build web server applications, web socket servers and more. But, what if we used ReactPHP to execute operations and tasks on a Drupal application?

Technically this could be feasible with a set of cron jobs scheduled at specific intervals which invoke Drush or Drupal Console commands. But there is a limitation there: The ability to manipulate cron jobs entries for the user whenever a deployment occurs. Hosting providers like Platform.sh support this, but only on the main application container. Worker containers do not support cron job definitions. Also, what about handling errors?

We can combine the ReactPHP event loop with the ReactPHP ChildProcess library to run our command line tools. The child process attaches to the event loop and allows us to stream output from STDOUT and STDERR. This allows us to log output from the command or handle errors which occur during these background processes.

It is easiest to create a function that executes a new child process, provides an event loop for it, and binds events to the output streams.  

function run_command(string $command): void {
  $loop = React\EventLoop\Factory::create();
  $process = new React\ChildProcess\Process($command);
  $process->start($loop);
  $process->on('exit', function ($exitCode) use ($command) {
    // Trigger alerts that the command finished.
  });
  $process->stdout->on('data', function ($chunk) {
    // Optinally log the output.
  });
  $process->stdout->on('error', function (Exception $e) use ($command) {
    // Log an error.
  });
  $process->stderr->on('data', function ($chunk) use ($command) {
    if (!empty(trim($chunk))) {
      // Log output from stderr
    }
  });
  $process->stderr->on('error', function (Exception $e) use ($command) {
    // Log an error.
  });
  $loop->run();
}

I am a big fan of Rollbar and have logs sent there.

Now, let's create our main event loop which will run our commands at different intervals. We can run cron every twenty minutes

$loop = React\EventLoop\Factory::create();
// Run cron every twenty minutes.
$loop->addPeriodicTimer(1200, function () {
  run_command('drush cron');
});
$loop->run();

If you're using a queuing system to process jobs asynchronously via the Advanced queue module, you may want a more continuous processing that acts as a daemon.


$loop = React\EventLoop\Factory::create();
// Every thirty seconds, process jobs from queue1
$loop->addPeriodicTimer(30, function () {
  run_command(sprintf('drush advancedqueue:queue:process queue1'));
});
// Every two minutes, process jobs from queue2
$loop->addPeriodicTimer(120, function () {
  run_command(sprintf('drush advancedqueue:queue:process queue2'));
});
$loop->run();

This has allowed us to run Drush as a child process in a ReactPHP event loop to run tasks. What if we actually bootstrapped Drupal and invoked our code directly instead of through child processes? We can!

To start, we need to bootstrap Drupal. This requires the creation of a request object and a mocked route. The DrupalKernel and other components are coupled to the request containing some meta information about a route. Luckily, Drupal supports a <none> route.

We require the autoloader and create our request object. I usually have my PHP scripts in a scripts directory at my project root, so my autoloader is in ../vendor/autoload.php.

$autoloader = require __DIR__ . '/../vendor/autoload.php';

$request = Symfony\Component\HttpFoundation\Request::createFromGlobals();
$request->attributes->set(
    Symfony\Cmf\Component\Routing\RouteObjectInterface::ROUTE_OBJECT,
    new Symfony\Component\Routing\Route('<none>')
);
$request->attributes->set(
    Symfony\Cmf\Component\Routing\RouteObjectInterface::ROUTE_NAME,
     '<none>'
);

Next, we bootstrap the DrupalKernel. We need to run the bootEnvironment method, this sets up some required information for Drupal. Next, we need to specify the site path which contains the settings.php we want to use; generally, it is sites/default. Then we just boot the kernel and run the pre-handle of the request to get everything up and running.

$kernel = new Drupal\Core\DrupalKernel('prod', $autoloader);
$kernel::bootEnvironment();
$kernel->setSitePath('sites/default');
Drupal\Core\Site\Settings::initialize($kernel->getAppRoot(), $kernel->getSitePath(), $autoloader);
$kernel->boot();
$kernel->preHandle($request);

Now we can have our event loop tick away and execute our code as needed.

$loop = React\EventLoop\Factory::create();
$loop->addPeriodicTimer(10, function () {
    $cron = \Drupal::service('cron');
    $cron->run();
  });
$loop->run();

Here is a link to a Github gist of the complete files exampled here: https://gist.github.com/mglaman/6f5b0b2194d2f5ec7f1a40d00dd5ca6c