Skip to main content

ReactPHP for Drupal deployments and workers

Published on

I recently held a live stream where I walked through the continuous integration and deployment (CI/CD) of a Drupal project to DigitalOcean's App Platform and other CI/CD items. App Platform has its quirks, but it's simple to build an application with various components. My project, Whiskey Dex, builds a Docker image that pushes to my container registry and then updates my App Platform manifest to use the new image tag, triggering a deployment. 

One thing it, and other similar platforms, lacks is the ability to perform operations once a deployment is finished. After a deployment, that is when you need to run schema migrations and other automated updates. It also is missing the ability to add a Cron component to run particular tasks on a schedule. Does provide Worker components, however. A worker is a service that isn't exposed over HTTP and will restart if its script exits or errors.

Previously I wrote about using ReactPHP to run Drush tasks at controlled intervals in a worker script. This approach was used to execute various tasks at different intervals when cron granularity was at a minimum of 5 minutes and also needed extra failure handling and reporting. What I didn't think about until recently was using the boot of my worker to handle deployment tasks.

My solution for Whiskey Dex deployments on App Platform to perform post-deployment actions and cron was to leverage a Worker component that executes a ReactPHP script. The main application serves Drupal over HTTP. The worker would run the script to perform my other required tasks in the background.

Worker script for deployments and cron

My deployment script is a really simple PHP script:

  • When executed, run database updates and configuration import
  • Every ten minutes, run Drupal's cron

This could be a pretty simple while(true) loop, where I manage the event loop myself and timing when cron was last executed. But I have plans for this to be scaled out and have concurrent operations for processing other queues. ReactPHP provides my event loop, intervals, and timers. This allows me to write event-driven and non-blocking code easily instead of managing it myself. I don't need to manage the while loop or track timing. Using the ChildProcess component from ReactPHP, I can include async processing with commands triggered at any point of the loop.

In my script, I add a timer to execute a task one second after the script has been launched. In reality, this probably could be changed to 0.1 for immediate execution.

Loop::addTimer(1.0, static function () {
  // run `updb`.
  (new Process(__DIR__ . '/vendor/bin/drush updb --yes'))
    ->on('exit', static function (?int $exitCode, $termSignal): void {
      // `updb` has finished, import config.
      (new Process(__DIR__ . '/vendor/bin/drush cim --yes'))->start();
    })
    ->start();
});

When the timer has elapsed, our callable is invoked. This creates a new process which execute's drush updb to perform database updates. Once that process has finished, it will run drush cim to import configuration. The benefit to using ReactPHP here is that I can also schedule other commands to run after drush updb has been started but before it or drush cim have finished. Truth be told, you can do this with Symfony's Process component as well. But, again, you're writing your own event loop and tracking each process yourself.

Finally, I have my cron task to run every ten minutes:

Loop::addPeriodicTimer(600.0, static function () {
  (new Process(__DIR__ . '/vendor/bin/drush cron'))->start();
});

In the future there will be multiple jobs running once a minute or more to process queues for event data that has been triggered by users interacting with the Drupal application.

Adding the worker to App Platform

The worker script is added as part of my Docker image that gets created and deployed. The script lives outside of the document root, so it cannot be accessed by the HTTP component. My worker is then configuring to set this script as its run command, instead of Apache. This ensures the worker is executing against the same code base as the HTTP component and is restarted at each deployment. 

Watch the live stream recording

Here is the recording from the live stream.