Skip to main content

Module Development

The Flavor Starter Theme ships a class-based module system ("Module System 2.0"). A module is a self-contained feature — a shortcode, an admin tool, a storefront enhancement — packaged as a single PHP class that extends Flavor\Modules\AbstractModule. The class carries all of its own metadata (id, version, category, name, description) as static methods, and the theme's ModuleManager asks the class for that metadata directly. There is no metadata array and no procedural entry file.

Scope

This is the extension point the built-in modules are built on. Ahead of public launch it is documented as an advanced / first-party API: it is stable enough to build against, but treat it as "how our module system works" rather than a frozen third-party contract with long-term stability guarantees.

Anatomy of a module

Each module lives in its own directory under flavor-starter/inc/modules/{category}/{id}/:

inc/modules/eshops/stock-alerts/
├── StockAlertsModule.php # The module class (required) — filename MUST end in "Module.php"
└── assets/ # CSS/JS (optional)
├── stock-alerts.css
└── stock-alerts.js

Rules that the loader relies on:

  • One class per module, extending AbstractModule.
  • The class file must end in Module.php — the theme has no PSR-4 autoloader, so the ModuleManager discovers first-party module files by scanning inc/modules/ for *Module.php and mapping each short class name to its path. A file named stock-alerts.php will not be found.
  • Namespace follows the folder: Flavor\Modules\Eshops\StockAlerts.
  • Everything else (settings, templates, assets) is up to the module.

The AbstractModule contract

Source: flavor-starter/inc/modules/AbstractModule.php.

Required — every module must implement these

MethodReturnsPurpose
id()stringUnique stable slug, kebab-case (e.g. stock-alerts). Used as the option-key suffix and DB reference.
version()stringStrict semver X.Y.Z only (validated at registration — 1.2, v1.2.3, 1.2.3-beta are rejected).
category()stringgeneral, eshops, marketing, tools, dev — or a custom slug.
name()stringHuman-readable name. Uses __('…', 'flavor') (see i18n note below).
description()stringShort description. Uses __('…', 'flavor').

All five are abstract public static — a module that omits any of them will not compile.

Optional — override only if you need to (sensible defaults shown)

MethodDefaultPurpose
icon()'admin-generic'Dashicon slug for the admin UI.
requiresPlugin()falsetrue if the module needs the WP eCommerce Core plugin active. Enforced at registration.
requiresPhp()'8.2'Minimum PHP version — checked by the compatibility gate.
requiresWp()'6.0'Minimum WordPress version — checked by the compatibility gate.
author()'FlavorTeam'Author name.
authorUri()'https://flavorteam.dev'Author URL.
external()falseSet to true for any non-built-in module (see "Built-in vs. external"). Affects the UI badge and activation-state tracking.
textdomain()'flavor'Textdomain used in name() / description().
requiresParentLicense()trueModule is skipped at boot unless the host theme/plugin license is valid. Built-in core modules override to false.

There are also several marketplace-ready methods (homepage(), supportUri(), license(), licenseUri(), minTier(), updateSource()) that exist so the metadata schema stays forward-compatible. They have no behavioural effect for built-in modules today — take the defaults unless you have a specific reason not to.

Lifecycle — instance methods you may override

Called by the ModuleManager on an active module instance (all default to no-ops):

MethodWhen it runs
boot()The module is active, passed the compatibility gate, and the license is valid. Register hooks, filters and shortcodes here.
activate()The user enables the module. One-time setup — create tables, seed defaults, register cron.
deactivate()The user disables the module. Unregister cron, clear transients; preserve settings for re-enable.
uninstall()Full uninstall. Delete settings, drop tables, purge state.
note
Constructor vs. boot()

The ModuleManager instantiates a module (new YourModule()) and then calls boot()both run at the same point, and only for a module that is active and licensed. The built-in modules currently register their hooks in the constructor (see IconsModule, StockAlertsModule); registering in boot() is equally valid and matches the documented contract. Pick one and be consistent. Do not register hooks in a static method — you only have an instance inside the constructor / boot().

Admin page (optional)

Override getAdminMenuConfig() to add a settings screen under the Flavor Modules menu. See Module Settings API for the exact array shape (note: the render key is callback).

What the ModuleManager does

Source: flavor-starter/inc/modules/ModuleManager.php. It is a singleton, booted on after_setup_theme (priority 20), and it owns the full lifecycle:

  1. Load active state — reads each module's module_{id} option to know which modules are switched on.
  2. Register built-in modules, then fire the flavor_register_modules action so child themes and plugins can register their own.
  3. Validate + gate each registration — the class must exist, extend AbstractModule, and pass strict semver + a PHP/WP/requiresPlugin() compatibility check. Failures are recorded (surfaced in the admin UI as "incompatible") rather than fatal.
  4. Initialise active modules — for each switched-on module that passes the parent-license gate: require the class file, new it (constructor runs), then call boot().

Metadata (name, version, icon, …) is read from the static methods, so the manager can list a module in the admin UI without instantiating it. A module is only instantiated when it is active.

Built-in vs. external modules

Built-in modules ship in inc/modules/ and are registered by the ModuleManager itself. Their active-state option keys are declared in the manager's built-in list.

External modules (yours) are registered on the flavor_register_modules action from a child theme or companion plugin. Two things matter for an external module to work end-to-end:

  • external() must return true. This is what makes the ModuleManager auto-track your module's activation state (module_{id} option, hyphens → underscores) so it can be switched on/off.
  • You must require_once your class file yourself. The manager's auto-loader only scans the theme's own inc/modules/ tree — it will not find a class outside the Flavor\Modules\ namespace. Require the file before you call register().

See Creating a Module for the full worked example.

Module lifecycle at a glance

Register (flavor_register_modules)
└─ validate: class_exists → subclass_of AbstractModule → strict semver → compatibility gate
└─ recorded in the registry (metadata read from static methods)
└─ active? (module_{id} option ON, license tier allows it)
└─ parent-license valid? (requiresParentLicense)
└─ require file → new YourModule() → boot()
└─ your hooks/shortcodes are live

Disabling a module simply flips its module_{id} option off — the class is never instantiated on the next load, so its hooks never register.

i18n note

name() and description() use WordPress __('…', 'flavor'), matching the AbstractModule contract and every built-in module. For runtime front-end strings inside your module logic, the theme also exposes its own translation layer, flavor_t() / flavor_te() — see the Translation docs. Keep module metadata on __() so it resolves the same way the manager and admin UI expect.

Next steps