Plugin Tutorial

Plugins are usually developed because there is a task that can not be completed with Grav's core functionality.

In this tutorial, we will create a plugin that helps Grav to deliver a random page to the user. You have probably seen similar functionality on blog sites as a way to provide a random blog-post when you click a button.

Because there is already a plugin that performs this job named Random, we'll call this test plugin Randomizer.

This feature is not possible out-of-the-box, but is easily provided via a plugin. As is the case with a great many aspects of Grav, there is no one-way to do this. Instead, you have many options. We will cover just one approach...

Randomizer Plugin Overview

For our plugin we will take the following approach:

  1. Activate the plugin if a URI matches our configured 'trigger route'. (e.g. /random)

  2. Create a filter so that only configured taxonomies are in the pool of random pages. (e.g. category: blog)

  3. Find a random page from our filtered pool, and tell Grav to use it for the page content.

OK! This sounds simple enough, right? So, let us get cracking!

Step 1 - Install DevTools plugin

Previous versions of this tutorial required creating a plugin manually. This whole process can be skipped thanks to our new DevTools Plugin

The first step in creating a new plugin is to install the DevTools Plugin. This can be done in two ways.

Install via CLI GPM

  • Navigate in the command line to the root of your Grav installation
bin/gpm install devtools

Install via Admin plugin

  • After logging in, simply navigate to the Plugins section from the sidebar.
  • Click the Add button in the top right.
  • Find DevTools in the list and click the Install button.

Step 2 - Create Randomizer plugin

For this next step you really do need to be in the command line as the DevTools provide a couple of CLI commands to make the process of creating a new plugin much easier!

From the root of your Grav installation enter the following command:

bin/plugin devtools new-plugin

This process will ask you a few questions that are required to create the new plugin:

bin/plugin devtools new-plugin
Enter Plugin Name: Randomizer
Enter Plugin Description: Sends the user to a random page
Enter Developer Name: Acme Corp
Enter Developer Email: [email protected]

SUCCESS plugin Randomizer -> Created Successfully

Path: /www/user/plugins/randomizer

Make sure to run `composer update` to initialize the autoloader

At this point you need to run composer update in the newly created plugin folder.

The DevTools command tells you where this new plugin was created. This created plugin is fully functional but will not automatically have the logic to perform the function we wish. We will have to modify it to suit our needs.

Step 3 - Plugin basics

Now we've created a new plugin that can be modified and developed. Let's break it down and have a look at what makes up a plugin. If you look in the user/plugins/randomizer folder you will see:

.
├── CHANGELOG.md
├── LICENSE
├── README.md
├── blueprints.yaml
├── randomizer.php
└── randomizer.yaml

This is a sample structure but some things are required:

Required items to function

These items are critical and your plugin will not function reliably unless you include these in your plugin.

  • blueprints.yaml - The configuration file used by Grav to get information on your plugin. It can also define a form that the admin can display when viewing the plugin details. This form will let you save settings for the plugin. This file is documented in the Forms chapter.
  • randomizer.php - This file will be named according to your plugin, but can be used to house any logic your plugin needs. You can use any plugin event hook to perform logic at pretty much any point in Grav's lifecycle.
  • randomizer.yaml - This is the configuration used by the plugin to set options the plugin might use. This should be named in the same way as the .php file.

Required items for release

These items are required if you wish to release your plugin via GPM.

  • CHANGELOG.md - A file that follows the Grav Changelog Format to show changes in releases.
  • LICENSE - a license file, should probably be MIT unless you have a specific need for something else.
  • README.md - A 'Readme' that should contain any documentation for the plugin. How to install it, configure it, and use it.

Step 4 - Plugin configuration

As we described in the Plugin Overview, we need to have a few configuration options for our plugin, so the randomizer.yaml file should look something like this:

enabled: true
active: true
route: /random
filters:
    category: blog

This allows us to have multiple filters if we wish, but for now, we just want all content with the taxonomy category: blog to be eligible for the random selection.

All plugins must have the enabled option. If this is false in the site-wide configuration, your plugin will never be initialized by Grav. All plugins also have the active option. If this is false in the site-wide configuration, each page will need to activate your plugin. Note that multiple plugins also support enabled/active in page frontmatter by using mergeConfig, detailed below.

The Grav default install has taxonomy defined for category and tag by default. This configuration can be modified in your user/config/site.yaml file.

Of course, as with all other configurations in Grav, it is advised not to touch this default configuration for day-to-day control. Rather, you should create an override in a file called /user/config/plugins/randomizer.yaml to house any custom settings. This plugin-provided randomizer.yaml is really intended to set some sensible defaults for your plugin.

Step 5 - Base plugin structure

The base plugin class structure will already look something like this:

<?php
namespace Grav\Plugin;

use Composer\Autoload\ClassLoader;
use Grav\Common\Plugin;
use RocketTheme\Toolbox\Event\Event;

/**
 * Class RandomizerPlugin
 * @package Grav\Plugin
 */
class RandomizerPlugin extends Plugin
{
    /**
     * Composer autoload.
     *
     * @return ClassLoader
     */
    public function autoload(): ClassLoader
    {
        return require __DIR__ . '/vendor/autoload.php';
    }
}

We need to add a few use statements because we are going to use these classes in our plugin, and it saves space and makes the code more readable if we don't have to put the full namespace for each class inline.

Modify the use statements to look like this:

use Composer\Autoload\ClassLoader;
use Grav\Common\Plugin;
use Grav\Common\Page\Collection;
use Grav\Common\Uri;
use Grav\Common\Taxonomy;

The two key parts of this class structure are:

  1. Plugins need to have namespace Grav\Plugin at the top of the PHP file.
  2. Plugins should be named in titlecase based on the name of the plugin with the string Plugin appended to the end, and should extend Plugin, hence the class name RandomizerPlugin.

Step 6 - Subscribed events

Grav uses a sophisticated event system, and to ensure optimal performance, all plugins are inspected by Grav to determine which events the plugin is subscribed to.

public static function getSubscribedEvents(): array
{
    return [
        'onPluginsInitialized' => [
            ['autoload', 100000], // TODO: Remove when plugin requires Grav >=1.7
            ['onPluginsInitialized', 0]
        ]
    ];
}

In this plugin we are going to tell Grav we're subscribing to the onPluginsInitialized event. This way we can use that event (which is the first event available to plugins) to determine if we should subscribe to other events.

Note: The first autoload event listener is only needed in Grav 1.6. Grav 1.7 automatically calls the method.

Step 7 - Determine if the plugin should run

The next step is to add a method to our RandomizerPlugin class to handle the onPluginsInitialized event so it only activates when a user tries to go to the route we have configured in our randomizer.yaml file. Replace the current 'sample' plugin logic with the following:

public function onPluginsInitialized(): void
{
    // Don't proceed if we are in the admin plugin
    if ($this->isAdmin()) {
        return;
    }

    /** @var Uri $uri */
    $uri = $this->grav['uri'];
    $config = $this->config();

    $route = $config['route'] ?? null;
    if ($route && $route == $uri->path()) {
        $this->enable([
            'onPageInitialized' => ['onPageInitialized', 0]
        ]);
    }
}

First, we get the Uri object from the Dependency Injection Container. This contains all the information about the current URI, including the route information.

The config() method is already part of the base Plugin, so we can simply use it to get the configuration value for our configured route.

Next, we compare the configured route to the current URI path. If they are equal, we instruct the dispatcher that our plugin will also listen to a new event: onPageInitialized.

By using this approach, we ensure we do not run through any extra code if we do not need to. Practices like these will ensure your site runs as fast as possible.

Step 8 - Display the random page

The last step of our plugin is to display the random page, and we can do that by adding the following method:

/**
 * Send user to a random page
 */
public function onPageInitialized(): void
{
    /** @var Taxonomy $uri */
    $taxonomy_map = $this->grav['taxonomy'];
    $config = $this->config();

    $filters = (array)($config['filters'] ?? []);
    $operator = $config['filter_combinator'] ?? 'and';

    if (count($filters) > 0) {
        $collection = new Collection();
        $collection->append($taxonomy_map->findTaxonomy($filters, $operator)->toArray());
        if (count($collection) > 0) {
            unset($this->grav['page']);
            $this->grav['page'] = $collection->random()->current();
        }
    }
}

This method is a bit more complicated, so we'll go over what's going on:

  1. First, we get the Taxonomy object from the Grav DI Container and assign it to a variable $taxonomy_map.

  2. Then we retrieve the array of filters from our plugin configuration. In our configuration this is an array with 1 item: ['category' => 'blog'].

  3. Check to ensure we have filters, then create a new Collection in the $collection variable to store our pages.

  4. Append all pages that match the filter to our $collection variable.

  5. Unset the current page object that Grav knows about.

  6. Set the current page to a random item in the collection.

Step 9 - Cleanup

The example plugin that was created with the DevTools plugin, used an event called onPageContentRaw(). This event is not used in our new plugin, so we can safely remove the entire function.

Step 10 - Final plugin class

And that is all there is to it! The plugin is now complete. Your complete plugin class should look something like this:

<?php
namespace Grav\Plugin;

use Composer\Autoload\ClassLoader;
use Grav\Common\Plugin;
use Grav\Common\Page\Collection;
use Grav\Common\Uri;
use Grav\Common\Taxonomy;

/**
 * Class RandomizerPlugin
 * @package Grav\Plugin
 */
class RandomizerPlugin extends Plugin
{
    /**
     * @return array
     *
     * The getSubscribedEvents() gives the core a list of events
     *     that the plugin wants to listen to. The key of each
     *     array section is the event that the plugin listens to
     *     and the value (in the form of an array) contains the
     *     callable (or function) as well as the priority. The
     *     higher the number the higher the priority.
     */
    public static function getSubscribedEvents(): array
    {
    return [
        'onPluginsInitialized' => [
            ['autoload', 100000], // TODO: Remove when plugin requires Grav >=1.7
            ['onPluginsInitialized', 0]
        ]
    ];
    }

    /**
     * Composer autoload.
     *
     * @return ClassLoader
     */
    public function autoload(): ClassLoader
    {
        return require __DIR__ . '/vendor/autoload.php';
    }

    public function onPluginsInitialized(): void
    {
        // Don't proceed if we are in the admin plugin
        if ($this->isAdmin()) {
            return;
        }

        /** @var Uri $uri */
        $uri = $this->grav['uri'];
        $config = $this->config();

        $route = $config['route'] ?? null;
        if ($route && $route == $uri->path()) {
            $this->enable([
                'onPageInitialized' => ['onPageInitialized', 0]
            ]);
        }
    }

    /**
     * Send user to a random page
     */
    public function onPageInitialized(): void
    {
        /** @var Taxonomy $uri */
        $taxonomy_map = $this->grav['taxonomy'];
        $config = $this->config();

        $filters = (array)($config['filters'] ?? []);
        $operator = $config['filter_combinator'] ?? 'and';

        if (count($filters) > 0) {
            $collection = new Collection();
            $collection->append($taxonomy_map->findTaxonomy($filters, $operator)->toArray());
            if (count($collection) > 0) {
                unset($this->grav['page']);
                $this->grav['page'] = $collection->random()->current();
            }
        }
    }
}

If you followed along, you should have a fully functional Randomizer plugin enabled for your site. Just point your browser to the http://yoursite.com/random, and you should see a random page. You can also download the original Random plugin directly from the Plugins Download section of the getgrav.org site.

Extending blueprints

If your plugin needs to extend blueprints, e.g. default.yaml from /system/blueprints/pages/default.yaml there's no need to register your blueprint via hooks if you respect the folder structure placing your extending blueprint inside of [your-plugin-directory]/blueprints/pages/default.yaml. Grav will merge your extended blueprint definitions while themes can do the same lateron.

system inheritance if you create this folder structure inside your plugin

  • blueprints
    • pages
      • default.yaml

admin inheritance if you create this folder structure inside your plugin

  • blueprints
    • admin
        • pages
        • raw.yaml

From admin you have to inherit raw or others and theme blueprints extending default won't make it into your configuration.

If it's not pages, do it the same way for other inheritances... This way you can keep extending changes at a minimum, that's what extending is all about :-).

Merging Plugin and Page Configuration

One popular technique that is used in a variety of plugins is the concept of merging the plugin configuration (either default or overridden user config) with page-level configuration. This means you can set site-wide configuration, and then have a specific configuration for a given page as needed. This provides a lot of power and flexibility for your plugin.

In recent versions of Grav, a helper method was added to perform this functionality automatically rather than you having to code that logic yourself. The SmartyPants plugin provides a good example of this functionality in action:

public function onPageContentProcessed(Event $event): void
{
    $page = $event['page'];
    $config = $this->mergeConfig($page);

    if ($config->get('process_content')) {
        $page->setRawContent(\Michelf\SmartyPants::defaultTransform(
            $page->getRawContent(),
            $config->get('options')
        ));
    }
}

Implementing CLI in your Plugin

Plugins have also the capability of integrating with the bin/plugin command line to execute tasks. You can follow the plugin CLI documentation if you desire to implement such functionality.

Found errors? Think you can improve this documentation? Simply click the Edit link at the top of the page, and then the icon on Github to make your changes.

Results