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
| Item | Convention | Example |
|---|---|---|
| Module id | kebab-case | sale-banner |
| Class | PascalCase + Module suffix | SaleBannerModule |
| Class file | must end in Module.php | SaleBannerModule.php |
| Namespace | matches the folder path | MyVendor\FlavorModules\SaleBanner |
| Settings option | module_{id}_settings | module_sale_banner_settings |
| CSS classes | prefix with the module id | .sale-banner-wrapper |
| JS globals | avoid — 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 modules — require_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()— neverget_option('flavor_*')/update_option(). - Cache:
flavor_cache_get()/flavor_cache_set()— neverset_transient(). flavor_cache_get()returnsnull(notfalse) on a miss — test!== null.- Never
flavor_set_option($key, null)to clear a value — useflavor_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
- Metadata array registration —
registerModule([...])no longer exists. Register a class:$manager->register(YourModule::class). - Missing
external()— a third-party module must returntruefromexternal()or its on/off state is never tracked (it can't be enabled). - Forgetting
require_once— the auto-loader only scans the theme's owninc/modules/. Require your class file beforeregister(). - Wrong file name — the class file must end in
Module.php. - Passing a group to
flavor_get_option()— reading takes two arguments only; there is no group parameter on get. get_option()/update_option()/set_transient()for theme data — use theflavor_*helpers.renderinstead ofcallbackingetAdminMenuConfig()— the admin layer readscallback.error_log()— useflavor_log().- Non-strict version —
version()must beX.Y.Zexactly (1.2,1.2.3-betaare rejected). data-lucideattributes for icons — render inline SVG instead.
Distribution (pre-launch)
When packaging a module:
- Declare requirements via the static methods (
requiresPhp(),requiresWp(),requiresPlugin()). - Version strictly —
Major.Minor.Patch, bumped on every change. - Test across states — module on/off, plugin present/absent, and (if
requiresParentLicense()is true) with and without a valid host license. - Include a README with install + usage notes.
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.]