Encapsulate configuration in WordPress plugins

Konfiguration und Programmlogik in WordPress Plugins trennen

In his blog post, Inpsyder David Naber explains why it is a good habit to capsule configuration handling in code and shows a package he developed.

Some times ago Inpsyder David Naber used to start every new plugin skeleton by creating a class like Vendor\Plugin\Config as he knew the domain is complex and implementation will require several configuration options. It took him two or three approaches to recognize that this is a good example of a reusable code that can be separated in a library. In his blog post he shows the resulting package and why it is a good habit to capsule configuration handling in your code.

What is Configuration?

First of all let me isolate the term configuration. Configuration means a dynamical way to affect the behavior of your software. In contrast to settings configuration is typically something that users don’t need to care about. And in contrast to static conventions (which is what PHP constants are for) it can be managed outside of the runtime of the software. So configuration aligns your software with the environment it runs in and how it interacts with other components. A low-level example would be where to send log messages to (either a file or a remote service) and what to send (every debug message or only warnings and alerts). But configuration can get way more complex when configuring a dependency injection container for example.

Let’s examine a real-world example I came across a while ago. The task was to transfer information from WordPress to a remote service and maintain the integrity of the payload. So basically the plugin should send an HTTP request that is signed with an RSA key pair. All the magic can be simplified and summarized like this:

<?php
class Handler {

    // Http Client
    private $client;

    // Signing service
    private $rsaSigner;

    public function handle(Event $event) { 

        $url = 'http://remote.service/endpoint';
        $rsaKey = [
            'public' => 'RSA Pub Key',
            'private' => 'RSA Priv Key',
            'password' => 't0p-s3cr3t',
        ];

        $request = new Request('POST', $url, $event->payload());
        $request = $this->rsaSigner->signRequest($request, $rsaKey);
        $this->client->send($request);
    }
}

It is an arbitrary event handler that sends a request to a remote URL. The handler object has a dependency on an HTTP client object and a service object that creates the RSA signature. Now the interesting parts in terms of configuration are the variables $url and $rsaKey which combines actually three configuration values. I decided to store the RSA key pair in the wp_siteoption database table due to the nature of RSA keys which are typically large strings (especially the private ones). As mentioned earlier this is nothing that a user must be worried about and so there isn’t any GUI to this site option. Instead, it is maintained using WP-CLI:

# Writing the content of a file into a WP option
$ cat id_rsa | wp site option add my_plugin_rsa_priv_key
$ cat id_rsa.pub | wp site option add my_plugin_rsa_pub_key

The password as well as the remote URL are provided as environment variables. These can be distributed on deployment and it does not leak a valid usable key if a database dump gets captured somehow. So let’s look at the example when we implement these settings:

<?php 
class Handler {

   // Http Client
    private $client;

    // Signing service
    private $rsaSigner;

    public function handle(Event $event) {
        $url = filter_var(
            getenv('MY_PLUGIN_REMOTE_URL'), 
            FILTER_VALIDATE_URL
        ); 
        $rsaKey = [
            'public' => get_site_option('my_plugin_rsa_pub_key'),
            'private' => get_site_option('my_plugin_rsa_pub_key'),
            'password' => getenv('MY_PLUGIN_RSA_PASS') ?: '',
        ];

        $request = new Request('POST', $url, $event->payload());
        $request = $this->rsaSigner->signRequest($request, $rsaKey);
        $this->client->send($request);
    }
}

A whole lot of new logic comes in now. The Handler now is responsible for filtering and type-checking of a configuration value (because the URL could be invalid or a value could not be set) which violates the single responsibility principle and it depends on the low-level database layer which breaks encapsulation. So this needs to be refactored. How about this:

<?php 
class Handler {

    // Http Client
    private $client;

    // Signing service
    private $rsaSigner;

    // Config Container
    private $config;

    public function handle(Event $event) {
        $url = $this->config->get('remote_url');

        $request = new Request('POST', $url, $event->payload());
        $request = $this->rsaSigner->signRequest($request);
        $this->client->send($request);
    }
}

I introduced another dependency on a configuration container object which the handler can read relevant information from. The RSA key itself was also removed from the handler as the signing service actually depends on the config container and reads configuration directly from there. So instead of reading configuration values from the source, the handler and the signing service depend on an interface to read configuration from. This gives several advantages but first and foremost better testability. But also you can rely on safe types and the code is completely independent of how the configuration is provided.

Let’s have a look at the config interface:

<?php
declare(strict_types=1);

namespace Inpsyde\Config;

use Inpsyde\Config\Exception\Exception;

interface Config
{

    /**
     * @throws Exception
     * @return mixed
     */    public function get(string $key);

    public function has(string $key): bool;
}

There are two methods that allow the client to read configuration values and ask whether values are set anyway. That allows it to read optionally set values. The get() method must throw an exception when an unknown key is requested or a value fails filtering as this would indicate clearly a bug in the code. The behavior might remind of PSR-11 however we expect different return types than from a dependency injection container. We decided to make this interface a separate package: inpsyde/config-interface so we can use it independent of the implementation that might be overkill for very small plugins.

The configuration package

Having an interface to depend on for common tasks is a good thing but proper implementation is the thing that saves you some work in the long run. And especially on the starting phase of larger projects plugins are kind of written on the assembly line. The main goals when starting the implementation were:

  • Reading configuration from common sources in a WordPress environment. These are mainly environment variables, PHP constants and the table wp_options and site options for the Multisite environment.
  • Allowing to filter raw values to ensure correct types and data consistency
  • Configurable via a simple and easy to use schema
  • Having a good test covered library

You can go ahead and decide whether these goals where reached: inpsyde/config. Anyway, let me explain the usage of this library using the example from above. Remember there were four configuration values: the remote URL, a private and public RSA key and the password of the key. Another very common configuration value of the plugin is the plugin root directory, so let’s add it to the example. To set up the configuration container, we create a file within the plugin config.php and write in the following schema array:

<?php
declare(strict_types=1);

namespace MyPlugin\Config;

use Inpsyde\Config\Source\Source;

return [
    'remote_url' => [
        // The configuration is read from an environment variable
        'source' => Source::SOURCE_ENV,
        // This is the name of the env variable
        'source_name' => 'PLUGIN_REMOTE_URL',
        // Optional: you can provide a default value as fallback if the variable is not set
        'default_value' => 'http://api.tld/endpoint',
        // Optional: If the variable is set, pass it to filter_var() using the following filter argument
        'filter' => FILTER_VALIDATE_URL,
    ],
    'rsa_pubkey' => [
        // In this case the option is read from WP site options
        'source' => Source::SOURCE_WP_SITEOPTION,
        // With this option key
        'source_name' => '_plugin_rsa_pubkey',
    ],
    'rsa_privkey' => [
        // In this case the option is read from WP site options
        'source' => Source::SOURCE_WP_SITEOPTION,
        // With this option key
        'source_name' => '_plugin_rsa_privkey',
    ],
    'rsa_key_password' => [
        // In this case the option is read from WP site options
        'source' => Source::SOURCE_ENV,
        // With this option key
        'source_name' => 'PLUGIN_KEY_PASSWORD',
    ],
    'plugin_dir' => [
        // The value is provided on instantiation time
        'source' => Source::SOURCE_VARIABLE,
    ],
];

This schema tells the configuration container where to read values from and whether to provide a default value or filter the raw values. Every configuration value is identified by a unique key. For each key there is a description of four properties: source, source_name, default_value and filter whereby the last two are optional. The library comes with a builder that creates the container object. The builder can read directly from a config file like the example shown above:

<?php
declare(strict_types=1);

namespace MyPlugin;

use Inpsyde\Config\Loader;

/* @var \Inpsyde\Config\Config $config */$config = Loader::loadFromFile(
    __DIR__.'/config.php',
    //Provide the variable value
    ['plugin_dir' => __DIR__ ],
);

// Remote URL
$remoteUrl = $config->get('remote_url');
// RSA Key parts
$rsaKey = [
    'pub' => $config->get('rsa_pubkey'),
    'priv' => $config->get('rsa_privkey'),
    'pass' => $config->get('rsa_key_password'),
];

You can find a complete and up-to-date documentation of the features at the plugin’s README.md.

Conclusion

Having a solid interface to separate your code from details of configuration reading and filtering is always a good thing regardless of how complex your plugin will be. That is why we decided to split the interface and the implementation into two separate packages. However, the inpsyde/config library can be a good option for you if your plugin has a couple of configuration values that need filtering or that are read from different sources like database, PHP constants or environment variables.

* Many thanks to Fatos Bytyqi for the photo we are using in this blog post header.