Andrew Welch · Insights · #craftcms #craft-3 #plugin

Published , updated · 5 min read ·


Please consider 🎗 sponsoring me 🎗 to keep writing articles like this.

Enhancing a Craft CMS 3 Website with a Custom Module

Enhanc­ing your clien­t’s Craft CMS 3 web­site with a Mod­ule lets you add cus­tom func­tion­al­i­ty with­out resort­ing to using or writ­ing a plugin

Craft-Cms-3-Yii2-Custom-Module

Some­times you want to enhance a client web­site with some func­tion­al­i­ty or design that’s very spe­cif­ic to that web­site. Cer­tain­ly you could do this with a cus­tom plu­g­in with scaf­fold­ing from plug​in​fac​to​ry​.io and fol­low­ing the So You Wan­na Make a Craft 3 Plu­g­in? article.

How­ev­er, for many things this just seems like too much work. Maybe you just want to enhance the look of the login screen to apply a back­ground image with the clien­t’s brand. A cus­tom plu­g­in seems like a bit much.

With Craft CMS 3, Craft intro­duces the con­cept of a Mod­ule, which fits the bill per­fect­ly for this type of scenario.

Link Modules vs. Plugins

The pri­ma­ry dif­fer­ences between a Mod­ule and a Plu­g­in are:

  • Plu­g­ins can be disabled
  • Plu­g­ins can be uninstalled
  • Plu­g­ins have a frame­work for Set­tings in the AdminCP

Oth­er than that, they are quite sim­i­lar. Both Mod­ules and Plu­g­ins are writ­ten in PHP, and can access the full Craft CMS APIs.

Note that you can have set­tings and AdminCP sec­tions in a Mod­ule as well, but you have to roll your own” via lis­ten­ing to the appro­pri­ate events, adding the appro­pri­ate routes, etc.

Modules are a perfect fit when the functionality is an integral part of the website

Even if you don’t con­sid­er your­self a PHP devel­op­er”, it’s pret­ty easy to get a sim­ple Mod­ule up and run­ning that will load some cus­tom CSS or JavaScript in the Craft AdminCP that enhances the expe­ri­ence for your client.

We’ll show you exact­ly how to do that in this article.

Link Modules Under the Hood

A nice way to think about Mod­ules is that they are Plu­g­ins that can’t be unin­stalled. They strike a nice bal­ance between being easy to imple­ment, and offer­ing the func­tion­al­i­ty of a plugin.

Module-Plugin-Balance

While it’s tempt­ing to think of Mod­ules are stripped down Plu­g­ins, the real­i­ty is that Plu­g­ins are actu­al­ly built on top of Modules!

So we should really be thinking of Plugins as enhanced Modules

Have a look at the code for craft\base\Plugin:

/**
 * Plugin is the base class for classes representing plugins in terms of objects.
 *
 * @property string           $handle   The plugin’s handle (alias of [[id]])
 * @property MigrationManager $migrator The plugin’s migration manager
 *
 * @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
 * @since  3.0
 */
class Plugin extends Module implements PluginInterface
{
...

What this is show­ing is that Craft CMS 3 Plu­g­ins are actu­al­ly Yii2 Mod­ules, but just with some enhance­ments added to them by Pix­el & Ton­ic. These enhance­ments allow plu­g­ins to be unin­stalled, have set­tings, AdminCP sec­tions, etc.

Note that you can have set­tings and AdminCP sec­tions in a Mod­ule as well, but you have to roll your own” via lis­ten­ing to the appro­pri­ate events, adding the appro­pri­ate routes, etc.

This fol­lows a theme that was dis­cussed in the Set­ting up a New Craft CMS 3 Project arti­cle, which is that Craft CMS 3 has been entire­ly refac­tored on top of Yii2.

Craft CMS 3 is now both a Content Management System (CMS) and a Content Management Framework (CMF)

This is an impor­tant point, because many cus­tom apps that would nor­mal­ly be built using a frame­work like Lar­avel very well may be built using Craft CMS 3. Check out the REST­ful API with Craft 3 for an exam­ple of doing just that!

Craft-Cms-3-Content-Management-Framework

This means that we’ll like­ly be see­ing Craft CMS 3 being used as a frame­work & foun­da­tion for web apps that want an awe­some CMS back­end for free. But I digress…

The rest of this arti­cle dis­cuss­es a cus­tom mod­ule in detail, but you can cre­ate our own on plug​in​fac​to​ry​.io as well:

Pluginfactory-Io-Custom-Modules

Link Setting Up a Site Module

So let’s talk about set­ting up an actu­al site mod­ule for our Craft web­site. All of the code list­ed here is avail­able in the site-mod­ule GitHub repo should you want to down­load it.

All our site mod­ule does is load an Asset Bun­dle that con­tains CSS and JavaScript that we want loaded in the AdminCP.

This allows you to do things like have a client brand back­ground image on the login screen, or to tweak the look & func­tion­al­i­ty of the AdminCP as you see fit via CSS & JavaScript.

Craft-Asset-Bundles

Mod­ules can do quite a bit more than this, in fact they can do any­thing a Plu­g­in can do. But this foun­da­tion allows a fron­tend devel­op­er to enhance their clien­t’s web­site with­out need­ing to get into the nit­ty grit­ty of how the mod­ule works.

You’ll find that if you used the composer create-project -s RC craftcms/craft PATH com­mand that Pix­el & Ton­ic rec­om­mends to cre­ate your new project, they’ve even pro­vid­ed a sam­ple config/app.php and modules/Module.php for you already. We’ve tweaked things a bit from this, so let’s get to it!

Here’s what the project tree looks like; again you can down­load the full source from the site-mod­ule GitHub page:

vagrant@homestead ~/webdev/craft/site-module (develop) $ tree -L 8 .
.
├── CHANGELOG.md
├── composer.json
├── config
│   └── app.php
├── LICENSE.md
├── modules
│   └── sitemodule
│       ├── CHANGELOG.md
│       ├── config
│       │   └── app.php
│       ├── LICENSE.md
│       ├── README.md
│       └── src
│           ├── assetbundles
│           │   └── sitemodule
│           │       ├── dist
│           │       │   ├── css
│           │       │   │   └── SiteModule.css
│           │       │   ├── img
│           │       │   │   └── SiteModule-icon.svg
│           │       │   └── js
│           │       │       └── SiteModule.js
│           │       └── SiteModuleAsset.php
│           ├── SiteModule.php
│           └── translations
│               └── en
│                   └── site-module.php
└── README.md

13 directories, 15 files

If it looks com­pli­cat­ed, don’t wor­ry about it. There are actu­al­ly more orga­ni­za­tion­al fold­ers than files there! There are essen­tial­ly 3 parts to it:

  1. Craft’s config/app.php
  2. The mod­ule itself in modules/sitemodule/src/SiteModule.php
  3. The Asset Bun­dle we load in modules/sitemodule/src/assetbundles/SiteAsset.php

We did­n’t have to name­space things with sitemodule/src but we want a fold­er to group every­thing con­tained in our mod­ule togeth­er (sitemodule) in case we have oth­er mod­els, and it’s a con­ven­tion to put all of our source code in a src sub-directory.

You could just as eas­i­ly get rid of those two direc­to­ries, and put every­thing inside of the modules/ direc­to­ry itself.

So let’s look at these three pieces in detail:

Link 1. Edit the config/app.php

The config/ direc­to­ry has a num­ber of con­fig files that you’re used to, like general.php, db.php, etc. used for var­i­ous set­tings in Craft CMS 3. But it also can have an app.php con­fig file.

The app.php con­fig file is super-pow­er­ful, in that it allows you to over­ride or extend any part of the Craft CMS 3 Yii2 app. Read that again, because it’s huge. With a sim­ple con­fig file, we can extend the Yii2 app that is Craft CMS 3, or we can replace func­tion­al­i­ty entirely.

We’re just going to dip our toe into it, and add a bit of code to it to tell it about our new Mod­ule, and to load it for us.

<?php
/**
 * Yii Application Config
 *
 * Edit this file at your own risk!
 *
 * The array returned by this file will get merged with
 * vendor/craftcms/cms/src/config/app/main.php and [web|console].php, when
 * Craft's bootstrap script is defining the configuration for the entire
 * application.
 *
 * You can define custom modules and system components, and even override the
 * built-in system components.
 */

return [

    // All environments
    '*'       => [
        'modules'   => [
            'site-module' => [
                'class' => \modules\sitemodule\SiteModule::class,
            ],
        ],
        'bootstrap' => ['site-module'],
    ],

    // Live (production) environment
    'live'    => [
    ],

    // Staging (pre-production) environment
    'staging' => [
    ],

    // Local (development) environment
    'local'   => [
    ],
];

We’re giv­ing Craft the class of our mod­ule, along with the han­dle site to refer to it by, then we’re telling it to load it for every request via bootstrap.

Link 2. The Module Class

Next up we have our Mod­ule class itself in modules/sitemodule/src/SiteModule.php. This is what is actu­al­ly loaded and exe­cut­ed on each request:

<?php
/**
 * Site module for Craft CMS 3.x
 *
 * An example module for Craft CMS 3 that lets you enhance your websites with a custom site module
 *
 * @link      https://nystudio107.com/
 * @copyright Copyright (c) 2018 nystudio107
 */

namespace modules\sitemodule;

use modules\sitemodule\assetbundles\sitemodule\SiteModuleAsset;

use Craft;
use craft\events\RegisterTemplateRootsEvent;
use craft\events\TemplateEvent;
use craft\i18n\PhpMessageSource;
use craft\web\View;

use yii\base\Event;
use yii\base\InvalidConfigException;
use yii\base\Module;

/**
 * Class SiteModule
 *
 * @author    nystudio107
 * @package   SiteModule
 * @since     1.0.0
 *
 */
class SiteModule extends Module
{
    // Static Properties
    // =========================================================================

    /**
     * @var SiteModule
     */
    public static $instance;

    // Public Methods
    // =========================================================================

    /**
     * @inheritdoc
     */
    public function __construct($id, $parent = null, array $config = [])
    {
        Craft::setAlias('@modules/sitemodule', $this->getBasePath());
        $this->controllerNamespace = 'modules\sitemodule\controllers';

        // Translation category
        $i18n = Craft::$app->getI18n();
        /** @noinspection UnSafeIsSetOverArrayInspection */
        if (!isset($i18n->translations[$id]) && !isset($i18n->translations[$id.'*'])) {
            $i18n->translations[$id] = [
                'class' => PhpMessageSource::class,
                'sourceLanguage' => 'en-US',
                'basePath' => '@modules/sitemodule/translations',
                'forceTranslation' => true,
                'allowOverrides' => true,
            ];
        }

        // Base template directory
        Event::on(View::class, View::EVENT_REGISTER_CP_TEMPLATE_ROOTS, function (RegisterTemplateRootsEvent $e) {
            if (is_dir($baseDir = $this->getBasePath().DIRECTORY_SEPARATOR.'templates')) {
                $e->roots[$this->id] = $baseDir;
            }
        });

        // Set this as the global instance of this module class
        static::setInstance($this);

        parent::__construct($id, $parent, $config);
    }

    /**
     * @inheritdoc
     */
    public function init()
    {
        parent::init();
        self::$instance = $this;

        if (Craft::$app->getRequest()->getIsCpRequest()) {
            Event::on(
                View::class,
                View::EVENT_BEFORE_RENDER_TEMPLATE,
                function (TemplateEvent $event) {
                    try {
                        Craft::$app->getView()->registerAssetBundle(SiteModuleAsset::class);
                    } catch (InvalidConfigException $e) {
                        Craft::error(
                            'Error registering AssetBundle - '.$e->getMessage(),
                            __METHOD__
                        );
                    }
                }
            );
        }

        Craft::info(
            Craft::t(
                'site-module',
                '{name} module loaded',
                ['name' => 'Site']
            ),
            __METHOD__
        );
    }

    // Protected Methods
    // =========================================================================
}

The __construct() method may look a lit­tle scary, but we’re just set­ting up a Yii2 alias to our Mod­ule’s direc­to­ry so we can use it lat­er, then set­ting things up so that our mod­ule can have trans­la­tions, and poten­tial­ly tem­plates in the AdminCP as well.

Just skip over that, and check out the init() method.

Here we check to make sure this is an AdminCP request (which are nev­er con­sole / com­mand line requests), and then lis­ten­ing for the EVENT_BEFORE_RENDER_TEMPLATE event.

This event is fired just before a Twig tem­plate is about to be ren­dered. This lets us load our Asset Bun­dle along with its CSS & JavaScript last, after every­thing else has been loaded.

This is great, because we usu­al­ly want to over­ride the look or func­tion­al­i­ty of some­thing in the AdminCP, and CSS Speci­fici­ty means that if we’re loaded last, we get a shot at doing just that.

Link 3. Our Asset Bundle

An Asset Bun­dle is just a col­lec­tion of arbi­trary resources such as CSS, JavaScript, images, etc. that need to be loaded and avail­able on the fron­tend. You can read more about Asset Bun­dles here.

This is what our modules/sitemodule/src/assetbundles/SiteAsset.php looks like:

<?php
/**
 * Site module for Craft CMS 3.x
 *
 * An example module for Craft CMS 3 that lets you enhance your websites with a custom site module
 *
 * @link      https://nystudio107.com/
 * @copyright Copyright (c) 2018 nystudio107
 */

namespace modules\sitemodule\assetbundles\SiteModule;

use Craft;
use craft\web\AssetBundle;
use craft\web\assets\cp\CpAsset;

/**
 * @author    nystudio107
 * @package   SiteModule
 * @since     1.0.0
 */
class SiteModuleAsset extends AssetBundle
{
    // Public Methods
    // =========================================================================

    /**
     * @inheritdoc
     */
    public function init()
    {
        $this->sourcePath = "@modules/sitemodule/assetbundles/sitemodule/dist";

        $this->depends = [
            CpAsset::class,
        ];

        $this->js = [
            'js/SiteModule.js',
        ];

        $this->css = [
            'css/SiteModule.css',
        ];

        parent::init();
    }
}

It just sets the sourcePath to our dist/ direc­to­ry, mean­ing that every­thing under the dist/ direc­to­ry is what should be pub­lished on the fron­tend in web/cpresources/ in a hashed direc­to­ry name.

Then it says that we depend on the AdminCP Asset­Bun­dle being loaded already, and gives a path to the CSS & JavaScript that we want inject­ed into the AdminCP templates.

All you real­ly need to under­stand from all of this is that every­thing in the dist/ direc­to­ry will be pub­lished in web/cpresources/ and the CSS & JavaScript we spec­i­fied will be loaded:

vagrant@homestead ~/webdev/craft/site-module/modules/sitemodule/src/assetbundles/sitemodule (develop) $ tree -L 3 .
.
├── dist
│   ├── css
│   │   └── SiteModule.css
│   ├── img
│   └── js
│       └── SiteModule.js
└── SiteModuleAsset.php

4 directories, 3 files

So you can mod­i­fy the Site.css and Site.js to your heart’s con­tent, and it’ll be loaded by our mod­ule in the AdminCP.

Link Making Composer Happy

To make Com­pos­er hap­py, we also need to make sure we have the fol­low­ing in our pro­jec­t’s composer.json file:

"autoload": {
    "psr-4": {
      "modules\\sitemodule\\": "modules/sitemodule/src/"
    }
  },

This just ensures that Com­pos­er will know where to find our mod­ules. You might also need to do:

composer dump-autoload

…from the pro­jec­t’s root direc­to­ry if you did­n’t already have the above in your composer.json, to rebuild the Com­pos­er autoload map. This will hap­pen auto­mat­i­cal­ly any time you do a composer install or composer update as well.

Link Modules in Action

Here’s a sim­ple exam­ple of a Mod­ule in action, on my new pod­cast web­site dev​Mode​.fm:

Devmode-Fm-Craft-Module

Using a lit­tle CSS, all it does is put our col­or­ful back­ground image on the login page:

/**
 * SiteModule CSS
 *
 * @author    nystudio107
 * @copyright Copyright (c) 2017 nystudio107
 * @link      https://nystudio107.com
 * @package   SiteModule
 * @since     1.0.0
 */

body.login {
    background-size: 600px;
    background-repeat: repeat;
    background-image: url('/img/site/devmode-fm-light-bg-opaque.svg');
}

body.login label, body.login #forgot-password {
    background-color: #FFF;
}

You can of course do quite a bit more than that in a Mod­ule. I recent­ly redid the nys​tu​dio107​.com web­site you’re read­ing right now to use Craft CMS 3 & Tail­wind CSS.

As part of that process, I rewrote a very site-spe­cif­ic Plu­g­in as a Mod­ule that loads some cus­tom CSS & JavaScript, reg­is­ters a cus­tom Redac­tor II plu­g­in, and more.

While the exam­ple pre­sent­ed here is rel­a­tive­ly sim­plis­tic, you can do things like reg­is­ter Fields, add Twig fil­ters, and oth­er such things from a Mod­ule just like you can from a Plugin.

The gen­er­al rule of thumb is that any­thing that’s very site-spe­cif­ic or busi­ness logic”-ish, you might want to refac­tor as a Mod­ule. Then just check it into the web­site’s main repos­i­to­ry, rather than hav­ing it as a sep­a­rate Plugin.

Head on over to plug​in​fac​to​ry​.io and build your own cus­tom Craft CMS 3 module!

Viva la modularity!