Creating a simple and configurable module for ProcessWire

In this tutorial, I'll demonstrate how you can build a module with;

  • A configuration page for storing user settings,
  • Hooks for injecting custom markup into HTML by modifying page render output,
  • and Hookable methods to provide more flexibility to users.

This module will be a wrapper around ga-lite. It's an unofficial Google Analytics client that only supports page view tracking. It also sends color depth, screen, and viewport size information. It has a footprint around 1KB, compared to official client at 12KB. This makes it a very lightweight alternative that can be self-hosted, allowing complete control over its caching.

The module will include a configuration page with a number of fields for setting tracking code and other options for ga-lite, and a few options for when and how it's injected into page.

Initial setup

Since we will be creating a tracking code and injecting it into the page, it should be a markup module, and we'll name the class with Markup prefix.

We'll start with building a skeleton by creating a folder in /site/modules/ named MarkupGALite, and inside it, a file named MarkupGALite.module. Our folder structure looks like:

/site/modules/MarkupGALite/
    MarkupGALite.module

Defining a class

Our module is made up of a class by the same name. Our class definition needs to accomplish three things. First, we need a class that extends WireData to access API variables using $this->page instead of calling wire() function every time 1. It also lets us get module settings directly using $this->someSetting.

Second, we need to implement the Module interface to mark the class as a module, and lastly, we need to implement the ConfigurableModule interface for configuration page support. Here's what the most basic form of a module looks like:

// /site/modules/MarkupGALite/MarkupGALite.module
<?php namespace ProcessWire;

class MarkupGALite extends WireData implements Module, ConfigurableModule
{
    public static function getModuleInfo()
    {
        return [
            'title' => 'Google Analytics Lite',
            'summary' => 'Adds Google Analytics tracking to pages using `ga-Lite`',
            'version' => '0.0.1',
            'author' => 'abdus',
            'href' => 'https://abdus.co',
            'icon' => 'bar-chart',
            'requires' => 'ProcessWire>=3.0',
            'autoload' => true
        ];
    }
}

ProcessWire gets information about the module by calling its getModuleInfo() method. Only title, summary and version fields needs to be present to introduce a module into Processwire. It's also possible to put module info into a separate file to keep module class more compact. Module documentation details how you can use the other methods.

Autoloaded modules are loaded on every request. As we need to put tracking code on every page, we should set its autoload property to true. There are three more options to state when a module is autoloaded: by specifying a selector string to match against the current page, by giving it a callable function that returns a boolean or by implementing the isAutoload() method and returning a boolean.

Now that the skeleton is complete, we can start coding. But first, you need to install and enable the module. For that, login to the backend, go to Modules and hit Refresh to clear module cache, then find MarkupGALite and install. Since it has no functional code yet, nothing remarkable will happen. But now any code we write will be run at each request.

Hooking into core

Hooks are how you modify the behaviour of a built-in method. When you see a method prefixed with three underscores, such as ___render() inside Page class 2, you should know that is a hookable method. There's a comprehensive documentation on ProcessWire website on what hooks are and how you can utilize them. There are many hookable methods inside core and in third party modules. @soma's Captain Hook, a cheatsheet of hooks available in ProcessWire core, is an invaluable tool that you need in your bookmarks.

Since we need to change the rendered markup before it's sent, we should hook into Page::render method 2.

We'll start with creating a public method called ready(), which is called when all autoload modules are loaded and API variables are available. There's also the init() method which is called after the module's __constructor() during the boot process. However, when this method is called, API variables are still not yet set, therefore $this->page will be null, for instance. Since we will need to know what page is requested later on, we will use ready() method to place our hooks and use $this->addHookAfter() method to hook into the page render process 3.

public function ready()
{
    // Hook into ___render() method in Page class
    $this->addHookAfter('Page::render', function(HookEvent $e) {
        // code here is run after Page::render method finishes
    });
}

Instead of working inside a closure, we can reference a method inside the module class using a different overload for addHookAfter method.

public function ready()
{
    $this->addHookAfter('Page::render', $this, 'onPageRender');
}

protected function onPageRender(HookEvent $e) { /* ... */ }

Inside this method, we'll inject the tracking code into HTML. We can inject before </head> or before </body>. Putting it before </head> is the recommended way of including tracking scripts. We'll use </head>, but also provide an option to pick </body> later on.

protected function onPageRender(HookEvent $e)
{
    $markup = $e->return;

    // check if there's <head> element
    // we shouldn't inject if this isn't a complete page
    if(!strpos($markup, '</head>')) return;

    $code = "<script>alert('working')</script>";

    // inject right before end of <head>
    $e->return = str_replace('</head>', "$code</head>", $e->return);
}

When you refresh any page, you should see an alert.

We'll use onPageRender for setting up and placing scripts in HTML, but refactor the part where tracking code is built into a hookable function. This will allow developers to change the tracking code as they see fit. After creating a public method called ___renderTrackingCode(), and moving $code = "...", we have:

protected function onPageRender(HookEvent $e)
{
    // ...

    // $code = "<script>alert('working')</script>";
    $code = $this->renderTrackingCode();

    // ... injection
}

public function ___renderTrackingCode() {
    return "<script>alert('working')</script>";
}

This modification doesn't change anything on the frontend, but it helps us achieve a better separation of concerns.

Integrating JS

After downloading ga-lite from its repository, extract the files under dist/ folder into the ga-lite/ in the module directory. Our folder structure should now look like:

/site/modules/MarkupGALite/
    MarkupGALite.module
    ga-lite/
        ga-lite.js
        ga-lite.min.js

We need the URL for these scripts to link them in HTML. Normally, inside templates $config->urls->MarkupGALite gives the URL for the module directory, /site/modules/MarkupGALite/. But inside modules, we need to use $this->config to get the $config API variable (or any API variable for that matter).

// instead of writing module's name, we can use $this
$scriptUrl = $this->config->urls->$this . "ga-lite/ga-lite.min.js";

Being so lightweight, ga-lite has only two configuration options; one to set tracking ID, and another to anonymize the IP address. Setting it up is quite easy:

var galite = galite || {};
galite.UA = 'UA-XXXXXX'; // tracking ID
galite.anonymizeIp = true;

We'll configure it inside an IIFE 4 to not pollute the global namespace

var galite = galite = {}; // this should stay in global namespace

(function(){
    var conf = {}; // we'll populate this using PHP
    galite.UA = conf.trackingId;
    galite.anonymizeIp = conf.anonymizeIp;
})();

Placing this into ___renderTrackingCode(), we've got:

public function ___renderTrackingCode() {
    $code = "";

    // instead of writing the module's name, we can use $this
    $scriptUrl = $this->config->urls->$this . "ga-lite/ga-lite.min.js";

    // we'll get these options using module configuration
    $options = [
        'trackingId' => 'UA-XXXXXX',
        'anonymizeIp' => true
    ];
    // and serialize it into JSON format
    $json = json_encode($options);

    $code .= "<script src='$scriptUrl'></script>";
    $code .= 
        "<script>
            var galite = galite = {};

            (function(){
                var conf = $json; // we're injecting the configuration as JSON
                galite.UA = conf.trackingId;
                galite.anonymizeIp = conf.anonymizeIp;
            })();
        </script>";

    return $code;
}

When you refresh and inspect the page source, you'll see the script injected into HTML.

<html>
    <head>
        <script src='/site/modules/MarkupGALite/ga-lite/ga-lite.min.js'></script>
        <script>
            var galite = galite = {};

            (function(){
                var conf = {"trackingId":"UA-XXXXXX","anonymizeIp":true};
                galite.UA = conf.trackingId;
                galite.anonymizeIp = conf.anonymizeIp;
            })();
        </script>
    </head>
    <body><!-- ... --></body>
</html>

Although the module works fine in its current form (with a real tracking ID, of course), it's not that flexible enough with these hardcoded values.

Building a configuration page

We need a text input for the tracking ID, and a checkbox for IP anonymization. We'll use the inputs built into the core. Although default inputs are plenty in size and shape, it's possible to use any 3rd party input, as well.

Because we've implemented ConfigurableModule interface, the form built inside getModuleConfigInputfields() method will show up on the module configuration page. This method takes in a single parameter of type for us to modify and return.

public function getModuleConfigInputfields(InputfieldWrapper $wrapper)
{
    // TRACKING ID ====================
    $tracking = $this->modules->InputfieldText;
    $tracking->name = 'trackingId';
    $tracking->label = 'Tracking ID';
    $tracking->value = $this->trackingId;
    $tracking->required = true; 
    // ... other properties
    $wrapper->add($tracking);

    // ANONYMIZE IP ====================
    $anonymize = $this->modules->InputfieldCheckbox;
    $anonymize->name = 'anonymizeIp';
    $anonymize->label = 'Anonymize IPs';
    // check if field is already populated, default to checked
    if(!isset($this->anonymizeIp)) $anonymize->attr('checked', 1);
    else $anonymize->attr('checked', $this->anonymizeIp);
    // ... other properties
    $wrapper->add($anonymize);

    return $wrapper;
}

Stating the name property of the field is enough to get user input and store in the database. But we should at least give it a proper label, and fill in the description and note properties to improve UX.

As the module extends the WireData class, accessing saved module settings is as simple as $this->someSetting. We'll use this to populate the fields with values loaded from the database.

We can fill an InputfieldText by assigning $field->value to some value. Since InputfieldText is essentially a wrapper around <input type="text">, setting its value to a possibly null value doesn't break anything, and shows up as an empty field, because null is interpreted as empty string "".

It is not that straightforward with InputfieldCheckbox. When a checkbox is checked, its checked attribute changes, not its value. And to uncheck it, the checked attribute needs to be removed 5. Another point to consider is by default, InputfieldCheckbox saves values as integer 1 for checked and empty string "" for unchecked 6. Using the isset() function, we can test if any saved value exists (if it is, it should be in $this->anonymizeIp), and default to checked by setting it to a truthy value.

After building a field, we add it to the $wrapper and return it at the end of the method. Now the configuration page for the module looks like:

Using configuration data

Inside ___renderTrackingCode() method, we have some hardcoded dummy values that needs replacing with user provided values.

public function ___renderTrackingCode()
{
    // ...

    $options = [
        'trackingId' => 'UA-XXXX',
        'anonymizeIp' => true
    ];

    // ...
    return $code;
}

We can refactor $options as a parameter, and create it inside the onPageRender() method.

protected function onPageRender(HookEvent $e)
{
    // ... 

    $options = [
        'trackingId' => $this->trackingId,
        'anonymizeIp' => !!$this->anonymizeIp // force boolean
    ];

    $code = $this->renderTrackingCode($options);

    // ...
}
public function ___renderTrackingCode(array $options)
{
    // ...

    $json = json_encode($options);

    // ...
}

The reason for converting $options to a parameter and not building the config inside ___renderTrackingCode() method is this method needs to be hookable, and any value that can be replaced should be accessable without much effort.

After these changes, we have a fully working module with a configuration page. But, we can improve it further.

Enhancements

This section outlines several possible improvements to our module.

Preventing hooks depending on a condition

Although we can set a field as required, ProcessWire merely suggests and does not prevent configuration with missing fields from being submitted. If a user left the tracking ID blank and submitted, $this->trackingId would be an empty string, and this wouldn't track anything. We need to deal with this issue.

Before we place our hooks in the ready() method, we can check whether the tracking ID is set and decide to hook or display & log a warning to user.

public function ready()
{
    // do not inject the code if tracking ID is not filled
    if(!empty($this->trackingId)) {
        // Hook into render() method in Page class
        $this->addHookAfter('Page::render', $this, 'onPageRender');
    } else {
        // $this is interpreted as the module name
        $this->warning("$this: Tracking ID is not filled, tracking code will not be injected", true);
    }
}

Detecting admin pages

There's no use in tracking backend usage, and we should avoid adding tracking script on admin pages.

We have several options to determine if a page is an admin page. We can check if the page's template is admin, or check whether the page's rootParent is admin, but these methods might fail in some edge cases. A more solid method is to check the URL of the page.

public function ready()
{
    // do nothing if it is admin page
    if(strpos($this->page->url, $this->config->urls->admin) === 0) return;

    // ... other checks
}

Adding additional configuration

In addition to the tracking ID and anonymize IP options, we can add a few more advanced configuration.

We can suggest injecting the contents of tracking script directly into the page. As ga-lite.js script is very lightweight, it doesn't bring a noticeable delay to page load. In fact, by eliminating a request, it usually accelerates page load 7, but it also means losing caching abilities. Since this option will be a simple on/off toggle, we will use another checkbox. We can also let the user select whether to place the script in the <head> or the <body>. For this option, we'll include a radio button group using InputfieldRadios.

Another option is to allow custom selectors to pick which pages the tracking code will be injected. While using text input is an option, InputfieldSelector is the perfect choice with built-in checks, sanitization and a specialized input field for constructing selectors.

To contain these additional fields, and to not scare off users with loads of options, we can wrap them inside a InputfieldFieldset and collapse it when no advanced configuration is specified.

public function getModuleConfigInputfields(InputfieldWrapper $wrapper) {
    // ... tracking ID field
    // ... anonymize IP field

    $advanced = $this->modules->InputfieldFieldset;
    $advanced->label = 'Advanced Settings';
    $advanced->collapsed = Inputfield::collapsedBlank;
    // ... other properties

    // SELECTOR =================
    $selector = $this->modules->InputfieldSelector;
    $selector->name = 'selector';
    $selector->label = 'Select pages';
    // fill it with any value saved before
    $selector->value = $this->selector;
    // ... other properties
    // add the field into advanced container, not into wrapper
    $advanced->add($selector);

    // INJECT SCRIPT ====================
    $inject = $this->modules->InputfieldCheckbox;
    $inject->name = 'inject';
    $inject->label = 'Inject script';
    // ... other properties
    // check if field is populated
    if(isset($this->inject)) $inject->attr('checked', $this->inject);
    $advanced->add($inject);

    // PLACEMENT ==================
    $place = $this->modules->InputfieldRadios;
    $place->name = 'placement';
    $place->label = 'Placement';
    $place->addOption('head', 'Before `</head>`');
    $place->addOption('body', 'Before `</body>`');
    $place->value = $this->placement ?: 'head';
    // ... other properties
    $advanced->add($place);

    // add the advanced container to wrapper
    $wrapper->add($advanced);
}

Once you refresh the configuration page, you can see the new fields.

We'll go back to the ready() function and add another check to use the selector:

public function ready()
{
    // ... check for admin pages

    // do nothing if page does not match the selector.
    if(isset($this->selector) // selector should be set
        && $this->selector // and it must not be empty
        && !$this->page->matches($this->selector)) return;

    // ... check for tracking ID
}

We'll modify ___renderTrackingCode() to support injecting script contents, and also change onPageRender to allow different placements. We will add an optional $inject parameter to ___renderTrackingCode(), and read the file contents if it resolves to true;

protected function onPageRender(HookEvent $e)
{
    // ...
    // figure out the position to place the script
    $pointer = $this->placement === 'body' ? '</body>' : '</head>';
    $pos = strpos($markup, $pointer);
    if (!$pos) return;

    // ...

    $code = $this->renderTrackingCode($options, !!$this->inject);

    // Place the script before end of head or body
    $e->return = str_replace($pointer, $code . $pointer, $e->return);
}

public function ___renderTrackingCode(array $options, $inject = false)
{
    $code = "";

    $scriptPath = __DIR__ . '/ga-lite/ga-lite.min.js';
    if($inject && file_exists($scriptPath)) {
        // inject the contents of the script
        $js = file_get_contents($scriptPath);
        $code .= "<script>$js</script>";
    } else {
        // link the script
        $scriptUrl = $this->config->urls->$this . "ga-lite/ga-lite.min.js";
        $code .= "<script src='$scriptUrl'></script>";
    }

    // ... build json and tracking code

    return $code;
}

Best practices

This section lays out best practices on module naming, setting default values, and providing descriptive information to users.

Naming modules

Although there's no strict guidelines on naming, it's good practice to stick to conventions. This keeps the Module Directory neat (if you ever plan to submit it) and helps other users understand its purpose at a glance.

Defaults and magic values

You should initialize the configuration values used inside the module constructor. Set them to sane default values, so you won't have to keep track of them using isset($this->field), and the module won't break if you forget to check it in one place.

public function __construct()
{
    // let parent set defaults
    parent::__construct();

    // override parent's defaults 
    // or provide defaults before they're populated with user values
    $this->set('placement', 'head');
    $this->set('trackingId', ''); // empty text field
    $this->set('anonymizeIp', 1); // checked checkbox
    $this->set('inject', ''); // unchecked checkbox
    $this->set('selector', ''); // empty selector
}

Also refactor strings as constants to make them reusable. This also makes them easier to spot, and risk of making a typo or introducing a bug is reduced.

class MarkupGALite extends WireData implements Module, ConfigurableModule
{
    const PLACE_HEAD = 1;
    const PLACE_BODY = 2;

    protected function onPageRender(HookEvent $e)
    {
        // ... 
        $pointer = $this->placement === $this::PLACE_BODY ? '</body>' : '</head>';
        // ...
    }

    public function getModuleConfigInputfields(InputfieldWrapper $wrapper)
    {
        // ...
        // PLACEMENT ==================
        $place = $this->modules->InputfieldRadios;
        // ...
        $place->addOption($this::PLACE_HEAD, 'Before `</head>`');
        $place->addOption($this::PLACE_BODY, 'Before `</body>`');

        // ...
    }
}

UX considerations

I removed some parts from the code snippets, and simplified some parts for better readability. However you should really consider filling in the description and the note properties for input fields. In addition, wrap strings with $this->_('...') function to mark them as translatable. This will allow others to translate the module without touching the source code.

Using the module

The module is now complete and works wonderfully, offering great customizability with a configuration page and hooks. Also it does not even need to be installed before using it. As long as it is inside the /site/modules/ directory, it can be used inside templates like so:

$ga = wire()->modules->MarkupGALite;

echo $ga->renderTrackingCode([
    'trackingId' => 'UA-123XX',
    'anonymizeIp' => true
]);

It can be hooked to change its parameters:

wire()->addHookBefore('MarkupGALite::renderTrackingCode', function(HookEvent $e){
    $options = $e->arguments(0);
    $inject = $e->arguments(1);

    $options['trackingId'] = 'UA-ANOTHER';
    $inject = false;

    $e->setArgument(0, $options);
    $e->setArgument(1, $inject);
});

or disabled altogether:

wire()->addHookBefore('MarkupGALite::renderTrackingCode', function(HookEvent $e){
    // $someCondition = ...

    if ($someCondition) $e->cancelHooks = true;
});

Last words

I was reluctant to build modules when I first started off with ProcessWire, but when I tried to build one I was amazed how easy and straightforward it was. Now by modularizing my code into individual modules, I can share code between projects easily without fearing something might break.

The tutorial might have been a bit more verbose than I'd like, but I think it's a complete guide to building a module from scratch. Hopefully, it will help you start building your own modules.

The full source code is available in my GitHub repo. It's not a verbatim copy of this tutorial, but it is commented throughly and includes the improvements and practices I've detailed before. Use it, break it apart, put it back together. Study it and build something better.


  1. Extending Wire or any class derived from it is enough to access API variables using $this->page⚓︎

  2. Page::render() actually is a reference to PageRender::___renderPage() method, and you won't be able to find a ___render() method in Page class. Look inside Page::get() in /wire/core/Page.php for this instance. ⚓︎⚓︎

  3. There's also $this->addHookBefore(), $this->addHook() and $this->addHookProperty()⚓︎

  4. Immediately-Invoked Function Expression. Check out Ben Alman's blog post on this topic. ⚓︎

  5. ProcessWire handles this case by skipping empty attributes by default. ⚓︎

  6. Although this behaviour can be changed with checkedValue and uncheckedValue properties, we won't change it and build the code around it.  ⚓︎

  7. Except for incredibly slow connections where load time for the script is larger than a roundtrip to the server. ⚓︎