Flamingruby

Using hooks to alter default behavior of ProcessWire

Hooks are the preferred way to alter how ProcessWire works. They're also something every ProcessWire developer should know at least the basics of.

ProcessWire has a solid default workflow involving typical content management tasks: creating, editing, saving, trashing and deleting pages, users, roles, permissions, etc. This workflow is almost always exactly what you want when working with so-called "regular websites".

More complicated structures, such as web apps or larger portal sites, introduce new kinds of requirements that can sometimes only be solved by making modifications to that default workflow. This is when hooks step in to save the day.

Hooking in a nutshell

Bulk of the ProcessWire codebase is object-oriented PHP. Simplifying things a bit, under the hood ProcessWire is powered by a bunch of interrelated objects, each of which consists of properties (variables) and methods (functions).

Many those methods are hookable – meaning in essence that you can write your own code that inserts ("hooks") itself before or after them. This makes it possible to manipulate the input and output values of these methods in various ways.

Each time a hookable method is triggered, code you've written can jump in and alter the way the program behaves, steering it in the direction you need it to go. Should the situation require more drastic measures, it's even possible to replace a whole method with code of your own (this is known as a "replace hook.")

You can also add your own methods or (dynamic) properties to an existing class or object, ones you can then call from your template files, modules, etc. HelloWorld.module has an example of this and I'll include two more in the code samples, so don't worry if this sounds confusing at the moment.

Just for the record, ProcessWire API documentation lists five types of hooks: before, after, replace, method, and property hooks. In this post I'll describe each of these, though some in more detail than others.

What's possible using hooks?

Real benefit of hooking is that it allows you to alter the flow of a program, even significantly – without overwriting a lot of existing code.

When a program provides built-in support for hooking, it also means that you won't need to make any core changes. This guarantees that things like upgrading the program will (most of the time) remain as simple as possible.

Some real life (as in "things already built by some smart folks within ProcessWire community") examples of hooking:

  • By default users login to ProcessWire with their usernames and passwords. With hooks you could alter the authentication part and let users login with, say, their e-mail addresses, personal identification numbers or something else.
  • Another login related example would be implementing external authentication service, such as Active Directory; instead of letting ProcessWire handle authentication you could send the credentials to that external service and let it decide if those credentials are correct.
  • ProcessWire runs page data through your templates and outputs the resulting markup. Sometimes you might want to add (or remove) something to/from this markup after it has already been generated. It's also possible to inject your own HTML markup, JavaScript or style rules into selected admin views.
  • Hooking into page save / publish is very typical example of hooks in action; things like sending a tweet each time new page has been published or alerting administrators when a change has been made are relatively easy to achieve.

For more ideas you could always browse the support forum for a while. There you'll find all kinds of things people have been using hooks for.

Identifying hookable methods

Hookable methods within ProcessWire source code are easy to identify: they all begin with ___ (three underscores). ProcessWire source is super easy to read and follow, but if you're looking for an even easier way, check out Captain Hook, a pre-generated list of available hooks.

For commandline savvy Linux-users this will also dig out all the hookable methods from your ProcessWire installation (might work on OS X and other Unix-like systems too):

find /path/of/processwire/ -regex ".*\.\(php\|module\)" -exec rgrep --color=always -H -n "function ___.*()" {} \;

If you're wondering, it's possible to make any method hookable locally simply by adding those three underscores before its name. That's quite useful if you want to make your own modules hookable, but modifying source of the ProcessWire installation you're running is something you'd usually like to avoid.

Upgrading modified ProcessWire can be complicated and any module relying on those modifications is, of course, likely to break if (and when) those upgrades are applied.

Introducing addHook() method

Hooking is a two-step process: you'll have to tell ProcessWire where you want to attach your hook and create the actual hook code. Former is achieved through a call to addHook() method, latter requires a function.

Probably the most common use case for hooks are modules (especially so-called autoload modules), but it's important to remember that hooking is also possible from your template files. Syntax is slightly different but the behavior is pretty much the same. Next chapter will explain both methods.

Hooks are attached with addHook() method of Wire class. This excerpt from Wire.php pretty much explains it:

<?php
/**
 * Hook a function/method to a hookable method call in this object
 *
 * Hookable method calls are methods preceded by three underscores. 
 * You may also specify a method that doesn't exist already in the class
 * The hook method that you define may be part of a class or a globally scoped function. 
 *
 * @param string $method Method name to hook into, NOT including the three preceding underscores. May also be Class::Method for same result as using the fromClass option.
 * @param object|null $toObject Object to call $toMethod from, or null if $toMethod is a function outside of an object
 * @param string $toMethod Method from $toObject, or function name to call on a hook event
 * @param array $options See self::$defaultHookOptions at the beginning of this class
 * @return string A special Hook ID that should be retained if you need to remove the hook later
 *
 */
public function addHook($method, $toObject, $toMethod, $options = array()) {

So, following explanation above attaching a hook after page has been saved looks something like this:

<?php
wire()->addHook("Page::save", null, "hookPageSave", array("after" => true));

Note that option "after" isn't really necessary there, as it's the default value. Quite often you'll also see people using shorthand methods for attaching hooks "before" or "after". This does same as the example above:

<?php
wire()->addHookAfter("Page::save", null, "hookPageSave");

Above examples attach your hook function to all instances of Page class. If you want to attach it only to one specific instance, syntax will be slightly different:

<?php
$page->addHookAfter("save", null, "hookPageSave");

One more thing to note is that if you wish to entirely replace original method (the one you're hooking into), this can only be achieved via "before" hook by setting $event->replace to true.

Code samples: hooking in action

To keep this post reasonably short I've only included the most important parts of sample code here. Rest I've included in HookExamples.module you can grab from GitHub.

It's actually fully functional module, so you can even try it out. I would be careful with the minify method, though – it's not properly tested and might cause unexpected issues. Consider yourself warned.

Hooking from a module

This code hooks into render method of page object and attempts to minify it's output if it looks like HTML – otherwise we'll just leave it intact.

<?php
/**
 * Initialization function. This is where we'll attach our hooks.
 *
 */
public function init() {

    // minify page markup automatically
    wire()->addHookAfter("Page::render", $this, "minifyHTML");

}

/**
 * Minify HTML markup
 *
 * Concept itself is very simple, i.e. getting event return value from
 * $event->return, modifying it and replacing original value with new
 * one. Here I've brought in some more advanced stuff mainly to prove
 * that hooks can be used for quite a few interesting things.
*
 * @param HookEvent $event
 */
public function minifyHTML(HookEvent $event) {

    // event return value contains rendered markup
    $markup = $event->return;

    // we don't want to attempt minifying markup unless it's actually HTML
    if (strpos($markup, "<html") === false) return;

    // Set PCRE recursion limit to sane value = STACKSIZE / 500
    if (php_uname('s') == "Windows") {
        ini_set("pcre.recursion_limit", "524"); // 256KB stack. Win32 Apache
    } else {
        ini_set("pcre.recursion_limit", "16777");  // 8MB stack. *nix
    }

    // minify markup with some regexp magic. Source for this solution and
    // those ini_set rows above was this excellent StackOverflow answer:
    // http://stackoverflow.com/questions/5312349/#answer-5324014
    $minified_markup = preg_replace(
        '%             # Collapse whitespace everywhere but in blacklisted elements.
        (?>            # Match all whitespans other than single space.
          [^\S ]\s*    # Either one [\t\r\n\f\v] and zero or more ws,
          | \s{2,}     # or two or more consecutive-any-whitespace.
        )              # Note: The remaining regex consumes no text at all...
        (?=            # Ensure we are not in a blacklist tag.
          [^<]*+       # Either zero or more non-"<" {normal*}
          (?:          # Begin {(special normal*)*} construct
            <          # or a < starting a non-blacklist tag.
            (?!/?(?:textarea|pre|script)\b)
          [^<]*+       # more non-"<" {normal*}
        )*+            # Finish "unrolling-the-loop"
        (?:            # Begin alternation group.
          <            # Either a blacklist start tag.
          (?>textarea|pre|script)\b
            | \z       # or end of file.
          )            # End alternation group.
        )              # If we made it here, we are not in a blacklist tag.
        %Six',
        ' ', 
        $markup
    );

    // add a comment containing decrease percentage (just for fun, really)
    $decrease = round((1-mb_strlen($minified_markup)/mb_strlen($markup))*100, 2);
    $comment = "<!-- minified by " . __CLASS__ . " ({$decrease}%) -->";
    $return = str_replace("<head>", "<head>{$comment}", $minified_markup);

    $event->return = $return;

}

Hooking from a template file

This code, when inserted in any of your template files (or common includes, such as a header file) adds new numSimilar property to all pages. When echoed or printed, this property simply outputs the number of pages with same template.

<?php
wire()->addHookProperty('Page::numSimilar', null, 'numSimilar');
function numSimilar(HookEvent $event) {
    $page = $event->object;
    $event->return = wire()->pages->count("template={$page->template}");
}

echo "There are {$page->numSimilar} similar pages.";
// example output:
// There are 5 similar pages.

When you're adding a hook from a template file, main difference to adding one from module is that you'll have to do it through the wire() function (in class context you could replace wire() with $this) and the second param is usually null. In module context second param would be $this, meaning that the hook method is within the scope of current object.

Adding a new property to object

Code below adds a new property ageStr to all pages, containing string interpretation about how long ago that page was created.

<?php
/**
 * Initialization function. This is where we'll attach our hooks.
 *
 */
public function init() {
    // Adding a new property to all pages: ageStr
    wire()->addHookProperty("Page::ageStr", $this, "pageAgeStr");
}

/**
 * Age of current page in string format ("5 days ago" etc.) Using
 * this in template files is easy: <?php echo $page->ageStr; ?>.
 * Function wireRelativeTimeStr() comes from /wire/core/Functions.php. 
 * 
 * @param HookEvent $event
 */
public function pageAgeStr(HookEvent $event) {
    $page = $event->object;
    $event->return = wireRelativeTimeStr($page->created);
}

More examples and resources

In case that you're feeling anxious about jumping in the hooking world, these resources should make your anxiety go away – or at least diminish it a bit:

You could (and should) also take a look at some of the many existing modules utilizing hooking:

Again, there are so many great examples and I could only pick a few here. You'll find many more from the ProcessWire modules directory – which, by the way, has really been growing lately with lots of awesome additions made by both new and old members of the ProcessWire community.

Wrapping it up

Most of my modules utilize hooking to some extent. It may not be as important to you as it is for me, but it's still a strategy you should be aware of. At the very least it's another tool in your toolbox – one never has too many of those, right?

Not everything being said about strategies like hooking is positive, though. Here is one pretty good – though very Drupal-sentric – blog post that brings up certain issues. Main point there, as far as I can tell, is that altering behavior of existing program to create something new can lead to complicated, messy and volatile situations.

I agree with some thoughts there, but having used ProcessWire in many different situations I've found it nothing short of amazing, even when a lot of customization is required. I guess it boils down to the fact that this is exactly what ProcessWire has been designed for, though I won't deny that modifying the behavior of any complicated software product can bring in unexpected issues and side effects.

Decisions like whether to use a CMF/CMS as a starting point for your application depend on a lot of different factors. Nothing works flawlessly for everyone and in every situation.

About the author

3 Comments

Martijn Geerts

Posted by Martijn Geerts on Thursday 31st of October 2013 21:13 pm

Excellent article Teppo. Much appreciated!

DaveP

Posted by DaveP on Friday 1st of November 2013 16:03 pm

What Martijn said.

Beluga

Posted by Beluga on Sunday 3rd of November 2013 16:29 pm

Please consider writing an article for http://www.skrolli.fi/ magazine and introducing PW to the Finnish geek massive :) PW would fit nicely within their hacker/maker perspective.

Skrolli even pays for submissions nowadays.

Post Comment