Modules Plugin

The Modules Plugin provides a robust, extendable architecture for managing business logic in a scalable and modular way. It enables developers to define core logic as Plugins and extend it using independent Modules.

Core Concepts

We have two primary component types: Plugin and Module.

  • Plugin is a system-level component. It describes the direct business logic of the project. Plugins can depend on each other because we guarantee that all of them exist in the system. When you need any plugin, you can get an instance of the Plugin and call the method you need.

  • Module is an extendable component. It extends the Plugin's logic or creates new business logic based on existing plugins.

  • Modules can depend on plugins, but cannot depend on other modules.

To get a Plugin instance:

$this->plugin->mail->send(...);
Core::getInstance()->getPluginInstance('Mail')->method(...);

To get a Module instance: Note: The module is always accessed by its class name without Plugin suffix (case-sensitive).

/** @var TestModulePlugin $plugin */
$plugin = ModulesManager::getInstance()->getModule('TestModule');
assert($plugin instanceof TestModulePlugin);

You can use events or observer listeners for communication between your module and plugins.


Create a New Module

They can be created at three levels:

  • System
  • Workspace
  • User

  • Create a plugin and implement the following interfaces:

    • IModule
    • (optionally) ISystemSpaceModule, IModuleMenuFetcher if needed.

  • Module Init onLoadSystemSpaceModule() If your module implements ISystemSpaceModule, you must override the onLoadSystemSpaceModule() method. Use it as empty method next to onInit().

    Each level requires its corresponding lifecycle method next to onInit():

    Level Interface Lifecycle Method
    System ISystemSpaceModule onLoadSystemSpaceModule()
    Workspace IWorkSpaceModule onLoadWorkSpaceModule()
    User IUserSpaceModule onLoadUserSpaceModule()

💡 These methods replace onInit() for module initialization.

  1. Create a SQL migration to register the module:
INSERT INTO modules (caption, ident, plugin, required, description, hooks, is_active, is_workspace)
VALUES ('Test Module', 'test_module', 'TestModule', NULL, 'Test description.', NULL, true, true);
  1. (optional) Create and implement an observer endpoint listener that you need, for example plugin\Modules\domain\event\IModuleStartListener. Also, you can describe an event into init.php in the Plugin.

Implement event listeners if your module needs to handle specific system events, for example:

For adding Menu item use onFetchModuleMenuHandler from IModuleMenuFetcher

use DisplayPlugin;
use plugin\Modules\IModule;
use plugin\Modules\domain\listener\IModuleMenuFetcher;
use plugin\Modules\domain\model\MenuItemValuesObject;

class TestModulePlugin extends DisplayPlugin implements IModule, IModuleMenuFetcher
{
   /**
    * @event IModuleMenuListener
    * @param array $menuItems
    * @param string|null $area
    * @return void
    */
    public function onFetchModuleMenuHandler(array &$menuItems, ?string $area): void
    {
        if (!$this->hasUserPermissionToSection('some_section')) {
            return;
        }

        $menuIdents = array_column($menuItems, MenuItemValuesObject::FIELD_IDENT);
        $idParentMenuItem = array_search('parent', $menuIdents);

        if (!$idParentMenuItem || empty($menuItems[$idParentMenuItem])) {
            return;
        }

        if (empty($menuItems[$idParentMenuItem][MenuItemValuesObject::FIELD_ITEMS])) {
            $menuItems[$idParentMenuItem][MenuItemValuesObject::FIELD_ITEMS] = [];
        }

        $menuItems[$idParentMenuItem][MenuItemValuesObject::FIELD_ITEMS][] = [
            MenuItemValuesObject::FIELD_CAPTION => __('Menu item 1'),
            MenuItemValuesObject::FIELD_URL     => $this->getUrl('/test/module/menu/'),
            MenuItemValuesObject::FIELD_IDENT   => 'menu-item-1',
        ];
    }
}

  1. Create SQL migration for the module (parameter is_workspace depends on the module level) :

for MySql

INSERT INTO `modules` (`caption`, `ident`, `plugin`, `required`, `description`, `hooks`, `is_active`, `is_workspace`)
VALUES ('Test Module', 'test_module', 'TestModule', NULL, 'Test description.', NULL, 1, 1);

for Postgres

INSERT INTO modules (caption, ident, plugin, required, description, hooks, is_active, is_workspace)
    VALUES ('Test Module', 'test_module', 'TestModule', NULL, 'Test description.', NULL, true, true);

Module options

(optional) If Module should have specific configuration options. You can add it to module_options table.

To add an option, create a SQL migration like this:

for MySql

SET @id_module_test = (SELECT `id`
                       FROM `modules`
                       WHERE `ident` = 'test_module');

INSERT INTO `module_options` (`id_module`, `caption`, `name`, `type`, `value`, `required`, `id_workspace`)
VALUES (@id_module_test, 'Test numbers', 'number', 'number', '3', 1, NULL);

for Postgres

DO $$
    DECLARE
        id_module_test integer;
    BEGIN
        SELECT id INTO id_module_test FROM modules WHERE ident = 'test_module';

        INSERT INTO module_options (id_module, caption, name, type, value, required, id_workspace)
        VALUES (id_module_test, 'Test numbers', 'number', 'number', '3', true, NULL);
    END;
$$;

In your module, you can then access these options:

use DisplayPlugin;
use plugin\Modules\IModule;

class TestModulePlugin extends DisplayPlugin implements IModule
{
   public function getNumber(): int
   {
      return (int) $this->getOption('number');
   }
}

Module events

If you need to create an extended endpoint in your Plugin or module, you can dispatch and listen to custom events using ModulesManager.

  1. Create an interface to describe the event listener method in the plugin folder:
namespace plugin\TestPlugin\domain\event;

use plugin\TestPlugin\domain\model\TestValuesObject;

interface ITestListener
{
    public function onTestHandler(TestValuesObject $testValuesObject): void;
}
  1. Now you can call your endpoint in one of these ways:
use plugin\Modules\ModulesManager;
use plugin\TestPlugin\domain\event\ITestListener;

ModulesManager::getInstance()->dispatchObserverEndpoint(
    ITestListener::class,
    function (ITestListener $instance, ...$args) {
        $instance->onTestHandler(...$args);
    },
    $testValuesObject
);

// OR

ModulesManager::getInstance()->dispatchObserverEndpointByMethodName(
    ITestListener::class,
    'onTestHandler',
    $testValuesObject
);
  1. Now, any module can Subscribe to the event inside any module:

Warning: Event arguments are passed by reference. Changing them affects the original data.

use DisplayPlugin;
use plugin\Modules\IModule;
use plugin\TestPlugin\domain\event\ITestListener;

class TestModulePlugin extends DisplayPlugin implements IModule, ITestListener
{
    public function onTestHandler(TestValuesObject $testValuesObject): void
    {
        // Handle the event
    }
}

Warning: Event arguments are passed by reference. Changing them affects the original data.


Store events

Modules can also subscribe to system events related to ModuleWorkSpacesStore.

Supported events:

BeforeUpdateModuleEvent

The BeforeUpdateModuleEvent class is an event that is triggered before a module is updated.

Triggered before a module is updated, use BeforeUpdateModuleEvent class

Method: * getModule(): Returns the ModuleValuesObject associated with this event.

public function onBeforeUpdateModuleHandler(BeforeUpdateModuleEvent $event): void
{
    $module = $event->getModule();
    // Your logic before updating the module
}

BeforeUpdateWorkSpaceModuleEvent

Triggered before a workspace module is updated.

Methods: * getModule(): Returns the ModuleValuesObject associated with this event. * getWorkSpace(): Returns the IWorkSpace associated with this event.

public function onBeforeUpdateWorkSpaceModuleHandler(BeforeUpdateWorkSpaceModuleEvent $event): void
{
    $module = $event->getModule();
    $workspace = $event->getWorkSpace();
    // Your logic before updating workspace module
}

IInstallableWorkSpaceModule

A lifecycle hook rather than an event: a workspace module implementing this interface is called once its own status in a workspace has just been written — after onLoadWorkSpaceModule() on enabling. Use it to put in place what the workspace needs to start using the module (a default record, a setting), and to take it away on disabling. Only the module whose status changes is called; other modules are not notified.

Both methods run inside the status transaction, so throwing rolls the change back.

use plugin\Modules\domain\scope\IInstallableWorkSpaceModule;

class TestModulePlugin extends DisplayPlugin implements IInstallableWorkSpaceModule
{
    public function onInstallWorkSpaceModule(IWorkSpace $workSpace): void
    {
        // Give the workspace what it needs to start using the module
    }

    public function onUninstallWorkSpaceModule(IWorkSpace $workSpace): void
    {
        // Usually nothing: the workspace keeps its records for the day the module comes back
    }
}

CreateWorkSpaceProviderEvent

Triggered to create a workspace provider.

Methods: * getWorkSpaceProvider(): Returns the IWorkSpaceProvider instance or null if not set. * setWorkSpaceProvider(IWorkSpaceProvider $workSpaceProvider): Sets the workspace provider instance.

public function onCreateWorkSpaceProviderHandler(CreateWorkSpaceProviderEvent $event): void
{
    $provider = $event->getWorkSpaceProvider();
    // Set your workspace provider if needed
}

BeforeDisplayModuleDictionaryEvent

Triggered before a module's dictionary is displayed in system scope — the page on /modules/manage/{idModule}/dictionaries/{ident}/ where the records a workspace template installs are authored.

One route serves every declared dictionary, so a handler that cares about only one of them tells them apart by the dictionary on the event.

Methods: * getStageBuilder(): The StageBuilder the page is built with. * getBreadcrumbs(): The breadcrumbs object, modified in place. * getModule(): The module the dictionary belongs to. * getDictionary(): The dictionary being displayed — ident, caption, table and store class. * getParentUrl() / setParentUrl(): Where the page returns to, the module catalogue by default.

public function onBeforeDisplayModuleDictionaryHandler(BeforeDisplayModuleDictionaryEvent $event): void
{
    if ($event->getDictionary()->getIdent() !== 'competencies') {
        return;
    }

    $event->setParentUrl($this->getUrl('/somewhere-else/'));
    $event->getBreadcrumbs()->setItems([...]);
}

Upgrade Plugin to Module

  1. To Update plugin you have to implement the following interfaces:
    • IModule
    • (optionally) ISystemSpaceModule, IModuleMenuFetcher if needed.

  2. Module Init onLoadSystemSpaceModule() If your module implements ISystemSpaceModule, you must override the onLoadSystemSpaceModule() method. It replaces the standard onInit() method in modules.

Important: Modules should keep onInit() method in the plugin. And just create an empty implementation of the onLoadSystemSpaceModule, onLoadWorkSpaceModule, or onLoadUserSpaceModule method depending on the module level.

Example: migration from onInit() to onLoadSystemSpaceModule() based on Books module: Previous plugin code (with onInit()):

public function onInit()
{
    parent::onInit();
    $this->_booksFacade = $this->_getBooksFacadeInstance();
}

Converted module code (with onLoadSystemSpaceModule()):

public function onInit()
{
    parent::onInit();
    $this->_booksFacade = $this->_getBooksFacadeInstance();
}

public function onLoadSystemSpaceModule(): void
{
}
Note: parent::onInit() is no longer needed in modules initialisation method. Only module-specific initialization should be kept inside onLoadSystemSpaceModule().

  1. Create a SQL migration to register the module and update navigation: DELETE OLD Menu Item from navigation ** add it to modules2workspaces** table if module is Workspace level .

INSERT INTO modules (caption, ident, plugin, required, description, hooks, is_active, is_workspace)
VALUES ('Test Module', 'test_module', 'TestModule', NULL, 'Test description.', NULL, true, true);
- Delete old navigation from database
DELETE FROM festi_menus
WHERE url = '/test_plugin_menu/';

DELETE FROM festi_url_rules
WHERE plugin = 'TestModule';
- Add new module relation to all workspaces
INSERT INTO modules2workspaces (id_module, id_workspace, is_active)
SELECT (SELECT id FROM modules WHERE ident = 'test_module'), id, TRUE
FROM schools
WHERE type = 'general';

  • Add Module options to module_options table if needed.

  • To show menu item in navigation Implement event listeners like it is described in module creation section:

    public function onFetchModuleMenuHandler(array &$menuItems, ?string $area): void

now you can get a Module instance like this:

/** @var TestModulePlugin $plugin */
$plugin = ModulesManager::getInstance()->getModule('TestModule');
assert($plugin instanceof TestModulePlugin);

or like a plugin if you kept onInit() method in the plugin:

$this->plugin->mail->send(...);
Core::getInstance()->getPluginInstance('Mail')->method(...);