Skip to main content

Module Development Best Practices

Conventions and guardrails for building Module System 2.0 modules. These mirror how the built-in modules are written.

Naming conventions

ItemConventionExample
Module idkebab-casesale-banner
ClassPascalCase + Module suffixSaleBannerModule
Class filemust end in Module.phpSaleBannerModule.php
Namespacematches the folder pathMyVendor\FlavorModules\SaleBanner
Settings optionmodule_{id}_settingsmodule_sale_banner_settings
CSS classesprefix with the module id.sale-banner-wrapper
JS globalsavoid — use closures/modules

The Module.php filename suffix is not cosmetic: the theme's first-party auto-loader discovers module files by scanning for *Module.php. A misnamed file is silently never loaded.

File organization

sale-banner/
├── SaleBannerModule.php # The class — metadata + hook wiring. Keep it lean.
├── includes/ # Extra PHP classes/helpers (require them from the module)
│ └── SaleBannerRepository.php
├── assets/
│ ├── sale-banner.css
│ └── sale-banner.js
└── templates/
└── banner.php # Output templates

The theme has no Composer autoloader for modulesrequire_once any extra class files yourself (from the constructor or boot()). Do not rely on composer dump-autoload for theme-side module classes.

Register hooks in the constructor or boot()

The ModuleManager instantiates and boots a module only when it is active and licensed, so both places run at the same, safe point. Register your add_action / add_filter / add_shortcode calls there — never in a static method (you have no instance) and never at file scope.

public function __construct()
{
add_action('flavor_header_before', [$this, 'render']);
add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']);
}

Use activate() for one-time setup (create tables, seed defaults) and deactivate() / uninstall() for teardown — see the Overview.

Declare dependencies with the contract, not guards

Prefer the static requirement methods over ad-hoc function_exists() checks — the ModuleManager enforces them at registration and shows a clear "incompatible" reason in the admin UI:

public static function requiresPlugin(): bool { return true; } // needs WP eCommerce Core
public static function requiresPhp(): string { return '8.2'; }
public static function requiresWp(): string { return '6.4'; }

If you still need a runtime guard inside a method (e.g. a plugin function that may not be loaded yet), degrade gracefully rather than fatal:

if (!function_exists('wpec_get_option')) {
return; // plugin not active — do nothing this request
}

Performance

Load assets only where needed

public function enqueueAssets(): void
{
if (!is_shop() && !is_product()) {
return;
}
wp_enqueue_style('sale-banner', /* … */, [], self::version());
}

Cache expensive work

$data = flavor_cache_get('sale_banner_data'); // returns null on miss
if ($data === null) {
$data = expensive_computation();
flavor_cache_set('sale_banner_data', $data, HOUR_IN_SECONDS, 'modules');
}

Security

Sanitize input, escape output, verify nonces

// Input
$title = sanitize_text_field($_POST['title'] ?? '');
$html = wp_kses_post($_POST['content'] ?? '');
$url = esc_url_raw($_POST['url'] ?? '');
$number = absint($_POST['count'] ?? 0);

// Output
echo esc_html($title);
echo esc_attr($class);
echo esc_url($link);
echo wp_kses_post($html_content);

// Nonce (settings form)
wp_nonce_field('sale_banner_save', 'sale_banner_nonce');
// … in the handler:
if (!wp_verify_nonce($_POST['sale_banner_nonce'] ?? '', 'sale_banner_save')) {
wp_die('Security check failed');
}

Also check the capability (current_user_can('manage_options')) before saving admin settings.

Data Sovereignty (absolute rules)

The theme owns all of its data in its own tables — never touch wp_options or WordPress transients for theme/module data.

  • Options: flavor_get_option() / flavor_set_option() / flavor_delete_option()never get_option('flavor_*') / update_option().
  • Cache: flavor_cache_get() / flavor_cache_set()never set_transient().
  • flavor_cache_get() returns null (not false) on a miss — test !== null.
  • Never flavor_set_option($key, null) to clear a value — use flavor_delete_option($key).
  • Plugin data (if your module needs it) uses the plugin's own helpers: wpec_get_option() / wpec_set_option().

Logging & debugging

Use the theme's unified logger — never error_log(). Nothing is written when FLAVOR_DEBUG is off, so it is safe to leave in place:

flavor_log('Sale banner rendered', 'debug', 'theme.module.sale-banner');

Signature: flavor_log(string $message, string $level = 'debug', string $channel = 'theme'). Use a hierarchical channel under theme.* (e.g. theme.module.{id}) so entries are filterable in the Debug viewer. Levels: debug, info, warning, error.

Common gotchas

Common mistakes
  1. Metadata array registrationregisterModule([...]) no longer exists. Register a class: $manager->register(YourModule::class).
  2. Missing external() — a third-party module must return true from external() or its on/off state is never tracked (it can't be enabled).
  3. Forgetting require_once — the auto-loader only scans the theme's own inc/modules/. Require your class file before register().
  4. Wrong file name — the class file must end in Module.php.
  5. Passing a group to flavor_get_option() — reading takes two arguments only; there is no group parameter on get.
  6. get_option() / update_option() / set_transient() for theme data — use the flavor_* helpers.
  7. render instead of callback in getAdminMenuConfig() — the admin layer reads callback.
  8. error_log() — use flavor_log().
  9. Non-strict versionversion() must be X.Y.Z exactly (1.2, 1.2.3-beta are rejected).
  10. data-lucide attributes for icons — render inline SVG instead.

Distribution (pre-launch)

When packaging a module:

  1. Declare requirements via the static methods (requiresPhp(), requiresWp(), requiresPlugin()).
  2. Version strictlyMajor.Minor.Patch, bumped on every change.
  3. Test across states — module on/off, plugin present/absent, and (if requiresParentLicense() is true) with and without a valid host license.
  4. Include a README with install + usage notes.
Component-aware installs

If your module ships as part of a Flavor product's own component pipeline, remember that installers extract the product first and then overlay signed components — a stale component can overwrite fresh files. This only concerns modules distributed inside Flavor's build system; a standalone child-theme module is unaffected. [VERIFY: confirm whether third-party modules are ever delivered through the component pipeline before publishing this note.]