Everything you need to know about Craft modules

Josh Crawford Josh Crawford Aug 2022 26 min read

Odds are you've probably heard the term "module" in the Craft world. If you've ever been told to "add this code to a module", but don't know where to begin, this guide is for you!

We'll cover all you need to know from the basics, the anatomy of a module, to best practices.

So what is a module?#

Modules are "containers" for PHP code that consist of models, views, controllers, and other supporting components. You can almost think of them as mini-applications, that are structured in an opinionated way. If you need to extend the behaviour of your Craft project using PHP, this is an excellent way to keep things organised.

Technically a Craft module can be considered a Yii module (opens new window) (the PHP framework Craft is built on), but seeing as though this module you're making is specifically for Craft CMS, we'll refer to it as a Craft module.

Plugin vs Module#

A Craft plugin and a Craft module are similar in almost every way. A Craft plugin is a type of module with a few things extra (installing, uninstalling, editions, available on the plugin store, etc). As such, if you learn how to develop a module, you're 98.56% of the way to developing Craft plugins.

The main difference with a module is that it's not something you redistribute like a plugin, and is instead heavily tied to your project. Think of them as permanent, site-specific plugins just for your project. It's the perfect place for any custom code that you require just for this project.

Create your first module#

Let's get stuck in creating our first module! We're going to create a "site" module, used for all sorts of miscellaneous things for our project. It's actually something we include on every site we build.

Want to get stuck in right away? Have a look at the Craft Generator (opens new window) package, which allows you to create all the components below with ease from the command line.

The first step when creating our module is to decide on a few things:

  • Namespace – The root namespace for our module’s classes. This will typically be in the format modules\mymodulename, but it can also be anything you like, but it should mimic your directory structure. See the PSR-4 (opens new window) autoloading specification for details.
  • Module ID – An ID to uniquely identify the module within your Craft project. Module IDs must begin with a letter and contain only lowercase letters, numbers, and dashes. They should be kebab-cased.

Let's settle on modules\sitemodule for the namespace and site-module for the module ID for the rest of this guide.

Depending on your site scale, it's highly recommended you split different functionality into different modules. Don't create a monolithic module that does everything, when it makes logical sense to split out and group similar functionality.

Next, create the following directory structure for the module, along with the main module class SiteModule.php.

my-project/
├── modules/
│    └── sitemodule/
│        └── src/
│            └── SiteModule.php
└── ...

Update the application config#

We'll firstly need to tell Craft to load this module when the Craft application starts. This can be done by editing the project's config/app.php file:

return [
    // ...
    'modules' => [
        'site-module' => \modules\sitemodule\SiteModule::class,
    ],
    'bootstrap' => [
        'site-module',
    ],
];

Create the main module class#

Then, we need to create our main module class. Add the following code to your SiteModule.php file. This file is the main entry point to your module.

<?php
namespace modules\sitemodule;

use Craft;
use yii\base\Module;

class SiteModule extends Module
{
    // Public Methods
    // =========================================================================

    public function init(): void
    {
        // Call the `Module::init()` method, which will do its own initializations
        parent::init();
        
        // Define a custom alias using the module ID
        Craft::setAlias('@site-module', __DIR__);
        
        // Your custom code will go here
        Craft::dd('Module loaded!');
    }
}

Set up class autoloading#

We need to tell Composer how to find the module’s classes by setting the autoload field in the project’s composer.json file.

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

Then, go to the project’s directory in a terminal, and run the following command:

composer dump-autoload -a

This will tell Composer to update its class autoloader script based on our new autoload mapping.

Testing things out#

With all that in place, let's test our module to ensure everything works!

Visit any page on your site (front-end or control panel) and if all goes well, you should see a completely white screen with red text Module loaded!. This is because the init() method of any module (or Craft plugin) is run before most other things, and on every page request. As such, you'll want to keep that in mind as you develop your module.

Using Craft::dd() is a quick and handy tool in your toolkit to quickly debug and check your work. FYI, it stands for "dump-and-die", which "dumps" the provided text and terminates the rest of the page request ("die"). You can even use it in Twig templates with {% dd myVariable %}

To continue with development, ensure you remove the Craft::dd('Module loaded!'); line, once you've verified things are loading correctly.

With the bare-bones set up for our module, let's cover all the different parts of a module and what you can do.

Anatomy of a module#

If you've ever inherited a PHP project from someone else, you're sure to understand the value of good code organisation. There's nothing worse than trying to find out where some bit of code is (looking at you custom WordPress plugins/themes). Fortunately, Craft is somewhat opinionated about where certain components sit in a module, which is great. The modules you build will be able to be easily understood by another developer — and future you!

Because modules are so similar to plugins, we're essentially outlining the components of a Craft Plugin, so this is a good summary to keep in mind if you're considering plugin development.

Let's step through the components of a module. Not every module will require all of these components, so this serves as a reference.

Asset Bundles#

Asset Bundles serve as a means to organise CSS and JS files for the front-end of your site, or in the control panel. While these CSS/JS files sit in your module directory, they are moved into your cpresources folder to be served as resources.

Read more via the Craft docs (opens new window).

Creating an asset bundle#

Create the following directory and file structure:

sitemodule/
├── src/
│    └── assets/
│        └── SiteAsset.php
│            └── js/
│                └── site.js
│            └── css/
│                └── site.css
└── ...

Add the following example asset bundle to your SiteAsset.php file.

<?php
namespace modules\sitemodule\assets;

use craft\web\AssetBundle;

class SiteAsset extends AssetBundle
{
    // Public Methods
    // =========================================================================

    public function init(): void
    {
        $this->sourcePath = '@site-module/assets';

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

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

        parent::init();
    }
}

Here, using the @site-module alias we set up in our base module class, we set the base path of the asset bundle to the modules/sitemodule/src/assets directory. Then, we instruct where any JavaScript and CSS files are relative to that base path. This asset bundle will also serve these files from your cpresources folder and handles caching.

To use this in your module you'll need to call registerAssetBundle() to publish the CSS and JS files to cpresources.

use modules\sitemodule\assets\SiteAsset;

Craft::$app->getView()->registerAssetBundle(SiteAsset::class);

Or, similarly in Twig:

{% do view.registerAssetBundle('modules\\sitemodule\\assets\\SiteAsset') %}

Controllers#

Controllers' primary responsibility is processing a request from the front-end or control panel, and generating a response back. This response could be a HTTP code, redirect, JSON and more.

A common pattern in Craft involves a controller action gathering post data, saving it on a model, passing the model off to a service, and then returning a response based on the service method’s result.

Read more via the Craft docs (opens new window).

Creating a controller#

Let's create a controller to give us an action endpoint, and return a response with JSON. This would be for use on the front-end of our project.

Create the following directory and file structure:

sitemodule/
├── src/
│    └── controllers/
│        └── DefaultController.php
└── ...

We've named our controller Default, but it's a good idea to name this something meaningful. Similarly, group related actions into a single controller. You also must include the Controller suffix to the class name.

To this file, add the following:

<?php
namespace modules\sitemodule\controllers;

use Craft;
use craft\web\Controller;
use yii\web\Response;

class DefaultController extends Controller
{
    // Properties
    // =========================================================================

    protected array|bool|int $allowAnonymous = ['get-data'];


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

    public function actionGetData(): Response
    {
        $data = ['data' => 'Some sample data'];

        return $this->asJson($data);
    }
}

We're using the allowAnonymous property, allowing the action to be triggered by guests (note the usage of kebab-case), and creating a actionGetData() function to return JSON.

To test this endpoint, we can now send a request to the following:

curl -X POST https://my-project.tld/actions/site-module/default/get-data

Each URL segment follows Yii’s conventions (opens new window) and is lower-kebab-cased:

  • The actionTrigger (opens new window), default to actions.
  • Your Module ID (from config/app.php), e.g. site-module.
  • The controller class, sans Controller suffix, e.g. DefaultController becomes default.
  • The controller function, sans action prefix, e.g. actionGetData() becomes get-data.

Console Commands#

Console Commands are a specific type of controller only available when triggering a request via the command line in a terminal. These are the perfect fit for long-running tasks or processes that you might not want to run within a queue job. This is because running these commands will use the PHP CLI environment instead of the PHP web environment settings, meaning there are no limits on memory consumption, timeouts, and more.

Read more via the Craft docs (opens new window).

Creating a console command#

Create the following directory and file structure:

sitemodule/
├── src/
│    └── console/
│        └── controllers/
│            └── MigrateController.php
└── ...

We're going to create a console command to migrate entries in a section. We'll want to supply a command argument for the section we want to perform migrations on, as well as provide formatted output on the command line.

Before we start, we'll need to add some code to our main plugin class, that looks at the request hitting our module, if it's a web request to use regular controllers, or if a console request to use console controllers.

// modules/sitemodule/src/SiteModule.php

public function __construct($id, $parent = null, array $config = [])
{
    // Set the controllerNamespace based on whether this is a console or web request
    if ($this->controllerNamespace === null && ($pos = strrpos(static::class, '\\')) !== false) {
        $namespace = substr(static::class, 0, $pos);

        if (Craft::$app->getRequest()->getIsConsoleRequest()) {
            $this->controllerNamespace = $namespace . '\\console\\controllers';
        } else {
            $this->controllerNamespace = $namespace . '\\controllers';
        }

        // Define a custom alias named after the namespace to help the controller resolve
        Craft::setAlias('@' . str_replace('\\', '/', $namespace), $this->getBasePath());
    }

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

Note that we've added this to a new __construct() method, because it needs to run before init(). It also creates another alias @modules/sitemodule which a controller will rely on to resolve correctly.

Hint: If you're using the verbb-base module, you don't need to include this.

Then, add to following code to MigrateController.php:

<?php
namespace modules\sitemodule\console\controllers;

use Craft;
use yii\console\Controller;
use yii\console\ExitCode;
use yii\helpers\Console;

class MigrateController extends Controller
{
    // Properties
    // =========================================================================

    public string $sectionHandle = '';


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

    public function options($actionID): array
    {
        $options = parent::options($actionID);

        // For each `actionID`, add options to the console command
        if ($actionID === 'section') {
            $options[] = 'sectionHandle';
        }

        return $options;
    }


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

    public function actionSection(): int
    {
        if (!$this->sectionHandle) {
            $this->stderr('You must provide a --section-handle option.' . PHP_EOL, Console::FG_RED);

            return ExitCode::UNSPECIFIED_ERROR;
        }

        $this->stdout("Starting migration for {$this->sectionHandle} ..." . PHP_EOL, Console::FG_YELLOW);

        // ... add your migration logic

        $this->stdout("Finished migration for {$this->sectionHandle} ..." . PHP_EOL, Console::FG_GREEN);

        return ExitCode::OK;
    }
}

Following the same endpoint path behaviour as regular controllers, you can then run this command action via:

./craft site-module/migrate/section --section-handle=blog

Elements#

Elements are special types of content objects, which are used for a variety of things in Craft. Entries, Categories, Users, Global Sets and more are all elements, which typically can be queried and have custom fields through field layouts.

Read more via the Craft docs (opens new window).

Fields#

You can create your very own custom fields for use in your project. Maybe a Craft core field needs a little tweaking (by extending it), or a third-party field in a plugin doesn't exist yet.

Read more via the Craft docs (opens new window).

Creating a field#

Create the following directory and file structure:

sitemodule/
├── src/
│    └── fields/
│        └── SectionField.php
│    └── templates/
│        └── section-field/
│            └── _input.html
│            └── _settings.html
└── ...

We'll create a new field that shows a dropdown field for users to be able to pick a section from. Typically, you'll have a Twig template file for the setting of the field (shown when you create the field) and the input of the field (shown when adding content to it via an element in the control panel).

First, we need to register our new field class in our SiteModule.php module class.

// modules/sitemodule/src/SiteModule.php

use craft\events\RegisterComponentTypesEvent;
use craft\services\Fields;
use modules\sitemodule\fields\SectionField;
use yii\base\Event;

public function init(): void
{
    // ...

    Event::on(Fields::class, Fields::EVENT_REGISTER_FIELD_TYPES, function(RegisterComponentTypesEvent $event) {
        $event->types[] = SectionField::class;
    });

    // ...
}

Then, let's populate the files for the field:

// modules/sitemodule/src/fields/SectionField.php

<?php
namespace modules\sitemodule\fields;

use Craft;
use craft\base\ElementInterface;
use craft\base\Field;
use craft\helpers\ArrayHelper;

class SectionField extends Field
{
    // Static Methods
    // =========================================================================

    public static function displayName(): string
    {
        return Craft::t('site', 'Section');
    }


    // Properties
    // =========================================================================

    public $defaultText = '';
    public $whitelistedSections = [];


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

    public function getInputHtml($value, ElementInterface $element = null): string
    {
        return Craft::$app->getView()->renderTemplate('site-module/section-field/_input', [
            'field'  => $this,
            'value' => $value,
            'options' => $this->getOptions($element),
        ]);
    }

    public function getSettingsHtml(): string
    {
        $sections = Craft::$app->getSections()->getAllSections();

        $options = array();

        foreach ($sections as $section) {
            $options[] = [
                'label' => $section->name,
                'value' => $section->handle,
            ];
        }

        return Craft::$app->getView()->renderTemplate('site-module/section-field/_settings', [
            'field' => $this,
            'options' => $options
        ]);
    }


    // Private Methods
    // =========================================================================

    private function getOptions(ElementInterface $element = null): array
    {
        $sections = $this->filterWhitelistedSections(Craft::$app->getSections()->getAllSections());

        $options = array();

        $options[] = [
            'label' => $this->defaultText != '' ? $this->defaultText : 'Select a section',
            'value' => '',
        ];

        foreach ($sections as $section) {
            $currentSite = $element->getSite();

            if (ArrayHelper::isIn($currentSite->id, $section->getSiteIds())) {
                $options[] = [
                    'label' => $section->name,
                    'value' => $section->handle,
                ];
            }
        }

        return $options;
    }

    private function filterWhitelistedSections($sections)
    {
        $sections = ArrayHelper::whereMultiple($sections, [
            'handle' => $this->whitelistedSections,
        ]);

        return $sections;
    }
}
// modules/sitemodule/src/templates/section-field/_input.html

{% import '_includes/forms' as forms %}

{{ forms.select({
    id: field.id,
    name: field.handle,
    value: value,
    options:  options,
}) }}
// modules/sitemodule/src/templates/section-field/_settings.html

{% import '_includes/forms' as forms %}

{{ forms.textField({
    label: 'Default Text' | t('site'),
    instructions: 'The default text shown in the dropdown when no option is selected.' | t('site'),
    id: 'defaultText',
    name: 'defaultText',
    placeholder: 'Select a section',
}) }}

{{ forms.checkboxGroupField({
    label: 'Allowed Sections' | t('site'),
    instructions: 'The sections that may be chosen for this field.' | t('site'),
    id: 'whitelistedSections',
    name: 'whitelistedSections',
    values: field.whitelistedSections,
    options: options,
}) }}

The content of your input and settings Twig templates is completely up to you! In our example, we've added a few settings for the field for our use-case.

We should now have a field type to create a new Section field with, add it to an entry type, and use it in entries.

Another example could be simply extending a Craft core field. Maybe you'd like to create a very specific type of input field, that extends from the craft\fields\PlainText field.

use craft\fields\PlainText;

class MySpecialInput extends PlainText
{
    // ...

Helpers#

Helper functions are an optional part of modules, and are where it's handy to store a collection of "helpers" functions. The rule of thumb is that any helper function should be a static function, which acts completely in its own scope.

If you find yourself needing to extract code that you re-use a lot, and want to be able to reference them like SiteHelper::myFunction() then helpers will sort you out!

// modules/sitemodule/src/helpers/SiteHelper.php

<?php
namespace modules\sitemodule\helpers;

class SiteHelper
{
    // Static Methods
    // =========================================================================

    public static function myFunction()
    {
        // ...
    }
}

Models#

A model typically represents a "thing" or object in your application or module. The general workflow is to populate a model with content via a controller, saving it to the database via a service, and rendering it in your Twig templates.

// modules/sitemodule/src/models/Customer.php

<?php
namespace modules\sitemodule\models;

use craft\base\Model;

class Customer extends Model
{
    // Public Properties
    // =========================================================================

    public $firstName;
    public $lastName;
    public $address;
    
    // ...
}

Services#

Services are where the bulk of your module's business logic should reside. This includes saving and fetching data from the database, processing data, and everything else. Typically, controllers will call service methods on a request to an endpoint, but service methods can also be called directly.

Read more via the Craft docs (opens new window).

Creating a service#

Let's create a service for fetching information on a subset of users.

Create the following directory and file structure:

sitemodule/
├── src/
│    └── services/
│        └── UsersService.php
└── ...

First, we need to register our new service as a "component" in our SiteModule.php module class. We're also going to create a getUsers() method as a bit of a best-practice so we can refer to the service easier.

use Craft;
use modules\sitemodule\services\UsersService;

public function init(): void
{
    // ...

    $this->setComponents([
        'users'  => UsersService::class,
    ]);

    // ...
}

public function getUsers(): UsersService
{
    return $this->get('users');
}

Then, add the following to the UsersService.php file:

<?php
use modules\sitemodule\services;

use yii\base\Component;

class UsersService extends Component
{
    // Public Methods
    // =========================================================================

    public function getSpecialUsers(): array
    {
        $users = [];

        // Add our logic to fetch "special" users.
        
        return $users;
    }
}

With all that in place, we can now call the service anywhere, in any of our modules.

use modules\sitemodule\SiteModule;

$specialUsers = SiteModule::getInstance()->getUsers()->getSpecialUsers();

Templates#

Your Twig templates are stored in the templates folder. These are different to your project's Twig templates for the front-end, and can be used to create Twig templates for things like custom fields (also in your module), templates rendered by your controllers, and more.

It's a good idea that if your module requires templates for components, they are stored within the module, not with the project's other Twig templates.

However, before your templates can work, you'll need to let Craft know about them! Add the following to your SiteModule.php module class.

use craft\events\RegisterTemplateRootsEvent;
use craft\web\View;
use yii\base\Event;

public function init(): void
{
    // ...

    // Register template roots to resolve our templates correctly
    Event::on(View::class, View::EVENT_REGISTER_CP_TEMPLATE_ROOTS, function(RegisterTemplateRootsEvent $event) {
        $event->roots[$this->id] = $this->getBasePath() . DIRECTORY_SEPARATOR . 'templates';
    });

    // ...
}

Here, we're telling Craft that any time we reference site-module/my-template-file we want it to resolve to modules/site-module/src/templates/my-template-file.

Hint: If you're using the verbb-base module, you don't need to include this.

Translations#

You can use Craft's translations functionality to easily translate content in your module for different languages or different sites. This is done through Static Message Translation (opens new window) where strings are converted to their respective site's version.

Whether you want to implement this in your modules is entirely up to you, and the scale of your site. If your site is small, and primarily in a single language, it may be overkill to translate your module into different languages. Unlike a plugin, your modules won't be distributed to other sites in many different languages!

Read more via the Craft docs (opens new window).

Twig Extensions#

You can also add your Twig extensions, allowing you to add your tags, filters, functions and more.

Read more via the Craft docs (opens new window).

Creating a Twig extension#

Let's create a new function and filter to use in our front-end templates:

  • An is_array() function to test if the variable we pass in is an array or not.
  • An is_string filter to test if the variable we pass is a string or not.

Create the following directory and file structure:

sitemodule/
├── src/
│    └── twigextensions/
│        └── Extension.php
└── ...

First, we need to register our new Twig extension class in our SiteModule.php module class.

use Craft;
use modules\sitemodule\twigextensions\Extension;

public function init(): void
{
    // ...

    Craft::$app->getView()->registerTwigExtension(new Extension);

    // ...
}

Then, add the following to your Extension.php file.

<?php
namespace modules\sitemodule\twigextensions;

use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigFilter;

class Extension extends AbstractExtension
{
    // Public Methods
    // =========================================================================

    public function getName(): string
    {
        return 'Site Module Twig Extension';
    }

    public function getFunctions(): array
    {
        return [
            new TwigFunction('is_array', [$this, 'isArray']),
        ];
    }

    public function getFilters()
    {
        return [
            new TwigFilter('is_string', [$this, 'isString']),
        ];
    }

    public function isArray($value): bool
    {
        return is_array($value);
    }

    public function isString($value): bool
    {
        return is_string($value);
    }
}

Here, we can supply an array of filters and functions, defining what to call them in Twig, and route them off to PHP functions to perform the logic.

You'll now be able to use these filters and functions in your templates!

{% set value1 = ['test'] %}
{% set value2 = 'test' %}

{{ is_array(value1) }}
{{ is_array(value2) }}
{{ value1 | is_string }}
{{ value2 | is_string }}

{# Outputs #}
true
false
false
true

Variables#

You're sure to have used {{ craft.entries... }} in your Twig templates, and in this case entries is a variable. You can also create your own, so we could use{{ craft.site... }}.

Read more via the Craft docs (opens new window).

Creating a variable class#

Create the following directory and file structure:

sitemodule/
├── src/
│    └── variables/
│        └── SiteVariable.php
└── ...

We'll create some functions to call in our templates. First, we need to register our new variables class in our SiteModule.php module class.

use craft\web\twig\variables\CraftVariable;
use modules\sitemodule\variables\SiteVariable;
use yii\base\Event;

public function init(): void
{
    // ...

    Event::on(CraftVariable::class, CraftVariable::EVENT_INIT, function(Event $event) {
        $event->sender->set('site', SiteVariable::class);
    });

    // ...
}

Then, add the following to your SiteVariable.php file.

<?php
namespace modules\sitemodule\variables;

class SiteVariable
{
    // Public Methods
    // =========================================================================

    public function welcomeMessage(): string
    {
        return 'It sure is swell to see you today 👋';
    }
}

It's a good idea to not store your logic in the variable class (unlike what we're doing here — it's just for a demo!), instead placing your logic in a service or helper function. As such, your variable class then becomes a type of API for your module in Twig for what can be accessed in your project templates.

{{ craft.site.welcomeMessage() }}

Widgets#

Modules can define widgets shown in the dashboard. Each widget can be added, removed or managed by users who have access to the dashboard in the control panel.

Read more via the Craft docs (opens new window).

Creating a widget#

Let's create an example widget that shows a random emoji.

Create the following directory and file structure:

sitemodule/
├── src/
│    └── widgets/
│        └── EmojiWidget.php
│    └── templates/
│        └── widgets/
│            └── emoji/
│                └── body.html
└── ...

We'll create some functions to call in our templates. First, we need to register our new variables class in our SiteModule.php module class.

use craft\events\RegisterComponentTypesEvent;
use craft\services\Dashboard;
use modules\sitemodule\widgets\EmojiWidget;
use yii\base\Event;

public function init(): void
{
    // ...

    Event::on(Dashboard::class, Dashboard::EVENT_REGISTER_WIDGET_TYPES, function(RegisterComponentTypesEvent $event) {
        $event->types[] = EmojiWidget::class;
    });

    // ...
}

Then, add the following to your EmojiWidget.php file.

<?php
namespace modules\sitemodule\widgets;

use Craft;
use craft\base\Widget;

class EmojiWidget extends Widget
{
    // Static Methods
    // =========================================================================

    public static function displayName(): string
    {
        return Craft::t('site', 'Emoji');
    }

    public function getBodyHtml(): ?string
    {
        $emojis = ['🤘', '🥳', '🍄', '🍔', '🌈', '⭐️'];

        $variables = [
            'emoji' => array_rand($emojis),
        ];

        return Craft::$app->getView()->renderTemplate('site-module/widgets/emoji/body', $variables);
    }
}
// modules/sitemodule/src/templates/widgets/emoji/body.html

{{ emoji }}

Using your module#

Now we've gone through all the different parts of a module, let's cover how you'll be using it in a real-world scenario. Commonly, you'll be asked to "add this code to a module", but where should we put that code? What are some examples of common things?

Listening to Craft events#

Through modules, you can trigger code depending on events raised by Craft. This can range from before an entry is saved, after a category is saved, before validation on a model, and even before the page request has rendered. It's also one of the most common ways to extend Craft.

There's a full list of events you can use on the Craft docs (opens new window) page.

Adding event listeners is done by adding code to your init() method in your main module class.

use craft\elements\Entry;
use craft\events\ModelEvent;
use yii\base\Event;

public function init(): void
{
    // ...

    // Perform an action when an entry is saved
    Event::on(Entry::class, Entry::EVENT_AFTER_SAVE, function(ModelEvent $event) {
        // Only run this if this is the first save
        if ($event->sender->firstSave) {
            // ...
        }
    });

    // ...
}

Registering Craft components#

Another common scenario is registering other components in your modules. This could be for registering a new field, element, widget and more.

Adding components is done by adding code to your init() method in your main module class.

use craft\events\RegisterComponentTypesEvent;
use craft\services\Dashboard;
use modules\sitemodule\widgets\MyWidget;
use yii\base\Event;

public function init(): void
{
    // ...

    // Register a custom widget
    Event::on(Dashboard::class, Dashboard::EVENT_REGISTER_WIDGET_TYPES, function(RegisterComponentTypesEvent $event) {
        $event->types[] = MyWidget::class;
    });

    // ...
}

Listening to Plugin events#

Like listening to Craft events, you can also listen to events raised by plugins — provided the plugin developer has made these events. As each plugin is different, the available events to listen to will be different, and each plugin will have documented these.

For example, with Formie you might like to listen to some of its events in your module.

Adding event listeners is done by adding code to your init() method in your main module class.

use verbb\formie\events\SubmissionEvent;
use verbb\formie\services\Submissions;
use yii\base\Event;

public function init(): void
{
    // ...

    // Perform an action when a Formie submission has been made
    Event::on(Submissions::class, Submissions::EVENT_AFTER_SUBMISSION, function(SubmissionEvent $event) {
        // ...
    });

    // ...
}

Keeping things organised#

It's a great idea to keep your events and component registrations organised — especially as your module starts to grow. Instead of putting everything in the init() method, we can split things up a little, and also look to improve performance.

use craft\elements\Entry;
use craft\events\ModelEvent;
use craft\events\RegisterComponentTypesEvent;
use craft\services\Dashboard;
use craft\web\twig\variables\CraftVariable;
use verbb\formie\events\SubmissionEvent;
use verbb\formie\services\Submissions;
use yii\base\Event;

public function init(): void
{
    parent::init();

    $this->_registerComponents();
    $this->_registerTwigExtensions();
    $this->_registerVariable();
    $this->_registerCraftEventListeners();
    $this->_registerThirdPartyEventListeners();

    if (Craft::$app->getRequest()->getIsCpRequest()) {
        $this->_registerWidgets();
    }
}

private function _registerComponents(): void
{
    $this->setComponents([
        // ...
    ]);
}

private function _registerTwigExtensions(): void
{
    // ...
}

private function _registerVariable(): void
{
    Event::on(CraftVariable::class, CraftVariable::EVENT_INIT, function(Event $event) {
        // ...
    });
}

private function _registerCraftEventListeners(): void
{
    Event::on(Entry::class, Entry::EVENT_AFTER_SAVE, function(ModelEvent $event) {
        // ...
    });
}

private function _registerThirdPartyEventListeners(): void
{
    Event::on(Submissions::class, Submissions::EVENT_AFTER_SUBMISSION, function(SubmissionEvent $event) {
        // ...
    });
}

private function _registerWidgets(): void
{
    Event::on(Dashboard::class, Dashboard::EVENT_REGISTER_WIDGET_TYPES, function(RegisterComponentTypesEvent $event) {
        // ...
    });
}

Here, we've split things up into private functions, which keeps things a little more organised and easier to find. We've also added a check for getIsCpRequest(), because there's no need to register widgets if the request isn't for the control panel. Likewise, you could use getIsSiteRequest() to check for front-end requests. It's a micro-performance improvement, every little bit helps with the speed of your site!

As a bonus, we'd even recommend moving large amounts of code in event hooks to a service function. This keeps your main module class lean.

private function _registerCraftEventListeners(): void
{
    // Offload the logic of the event hook to a `UsersService::onEntryAfterSave()` function
    Event::on(Entry::class, Entry::EVENT_AFTER_SAVE, [$this->getUsers(), 'onEntryAfterSave']);
}
// modules/sitemodule/src/services/UsersService.php

public function onEntryAfterSave(ModelEvent $event): void
{
    // ...
}

Base Module#

In order to get things like translations, templates, asset bundles, controllers and console controllers working, you'll be required to add some configuration to support that. This is all described above, but as a consolidated, all-in-one, copy-paste code block:

namespace modules\sitemodule;

use Craft;
use craft\events\RegisterTemplateRootsEvent;
use craft\helpers\ArrayHelper;
use craft\i18n\PhpMessageSource;
use craft\web\View;

use yii\base\Event;
use yii\base\Module as YiiModule;

class Module extends YiiModule
{
    // Properties
    // =========================================================================

    public $t9nCategory;
    public string $sourceLanguage = 'en-AU';


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

    public function __construct($id, $parent = null, array $config = [])
    {
        $this->t9nCategory = ArrayHelper::remove($config, 't9nCategory', $this->t9nCategory ?? $id);
        $this->sourceLanguage = ArrayHelper::remove($config, 'sourceLanguage', $this->sourceLanguage);

        if (($basePath = ArrayHelper::remove($config, 'basePath')) !== null) {
            $this->setBasePath($basePath);
        }

        // Translation category
        $i18n = Craft::$app->getI18n();
        if (!isset($i18n->translations[$this->t9nCategory]) && !isset($i18n->translations[$this->t9nCategory . '*'])) {
            $i18n->translations[$this->t9nCategory] = [
                'class' => PhpMessageSource::class,
                'sourceLanguage' => $this->sourceLanguage,
                'basePath' => $this->getBasePath() . DIRECTORY_SEPARATOR . '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 plugin class
        static::setInstance($this);

        // Set the default controller namespace
        if ($this->controllerNamespace === null && ($pos = strrpos(static::class, '\\')) !== false) {
            $namespace = substr(static::class, 0, $pos);

            if (Craft::$app->getRequest()->getIsConsoleRequest()) {
                $this->controllerNamespace = $namespace . '\\console\\controllers';
            } else {
                $this->controllerNamespace = $namespace . '\\controllers';
            }

            // Define a custom alias named after the namespace
            Craft::setAlias('@' . str_replace('\\', '/', $namespace), $this->getBasePath());
        }

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

That's a lot of code - but it's required to get Craft to know about the features of your module. Save that to a Module.php file in the root of your sitemodule/src folder.

Now, modify your SiteModule.php file (your main module class) to extend from this class, instead of the Yii module class. If you have multiple modules, they should also do the same thing by extending from this new Module.php class.

namespace modules\sitemodule;

use modules\sitemodule\Module;

class SiteModule extends Module
{
    public function init()
    {
        // ...
    }
}

Using Verbb's base module#

Now you can certainly copy that new Module.php class from project-to-project, but there's an easier way! You can use the verbb-base (opens new window) package that all Verbb plugins use, where we store this exact module.

To use it, run the following to include the package in your project. If you're using a Verbb plugin, it'll already be bundled in there!

composer require verbb\base

Then, you can delete your Module.php file, and any of the boilerplate code for templates, console commands, etc.

namespace modules\sitemodule;

use verbb\base\base\Module;

class SiteModule extends Module
{
    public function init()
    {
        // ...
    }
}

This will hopefully make spinning up new modules even quicker and keep your modules lean and mean.

Troubleshooting#

You may encounter an issue where you've added the correct file in place, but Craft doesn't recognise it. For example, you might've added a new Controller class, but Craft returns an error saying it cannot be found.

This can commonly be an issue with Composer's autoloading of classes not being up to date. A quick refresh of these should kick things into gear.

composer dump-autoload -a

Resources#

There's loads of other resources out there when it comes to modules, and you can also reference information about plugins. Here's a few resources to continue your learning with: