Extension Development
Learn how to build your own MDS extensions using the official skeleton plugin and best practices.
Getting Started
Use the Skeleton Plugin
The MDS Skeleton plugin provides a clean starting point for new extensions:
- Copy the
mds-skeletonfolder from the extensions repository - Rename the folder and main PHP file to match your extension name
- Update namespaces and text domain throughout
- Run
composer installto set up autoloading - Activate and test in WordPress
File Structure
A typical MDS extension follows this structure:
my-extension/
├── my-extension.php # Main plugin file (bootstrap)
├── README.md # Documentation
├── readme.txt # WordPress.org-style readme
├── composer.json # Composer dependencies & autoload
├── assets/
│ ├── css/
│ │ ├── admin.css # Admin styles
│ │ └── frontend.css # Frontend styles
│ └── js/
│ ├── admin.js # Admin JavaScript
│ └── frontend.js # Frontend JavaScript
├── src/
│ └── Plugin.php # Main plugin class
├── includes/ # Additional PHP classes (alternative to src/)
├── languages/
│ └── my-extension.pot # Translation template
└── tests/
└── ... # PHPUnit test suite
Essential Components
Main Plugin File
The main plugin file bootstraps your extension:
<?php
/**
* Plugin Name: My MDS Extension
* Plugin URI: https://example.com/my-extension
* Description: Description of what your extension does.
* Version: 1.0.0
* Author: Your Name
* Author URI: https://example.com
* Text Domain: my-mds-extension
* Domain Path: /languages
* Requires at least: 6.7
* Requires PHP: 8.1
* License: GPL-2.0-or-later
*/
// Prevent direct access
if (!defined('ABSPATH')) {
exit;
}
// Define constants
define('MY_EXT_VERSION', '1.0.0');
define('MY_EXT_DIR', plugin_dir_path(__FILE__));
define('MY_EXT_URL', plugin_dir_url(__FILE__));
define('MY_EXT_BASENAME', plugin_basename(__FILE__));
// Composer autoloader
if (file_exists(MY_EXT_DIR . 'vendor/autoload.php')) {
require_once MY_EXT_DIR . 'vendor/autoload.php';
}
// Initialize after plugins are loaded (ensures MDS is available)
add_action('plugins_loaded', function () {
// Check if MDS core is active
if (!class_exists('MillionDollarScript')) {
add_action('admin_notices', function () {
echo '<div class="notice notice-error"><p>';
echo esc_html__('My MDS Extension requires Million Dollar Script to be installed and activated.', 'my-mds-extension');
echo '</p></div>';
});
return;
}
// Initialize the extension
My_Extension\Plugin::instance();
});
Plugin Class
The main plugin class handles initialization and hooks:
<?php
namespace My_Extension;
class Plugin {
private static ?Plugin $instance = null;
public static function instance(): Plugin {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->init_hooks();
}
private function init_hooks(): void {
// Load translations
add_action('init', [$this, 'load_textdomain']);
// Admin hooks
add_action('admin_menu', [$this, 'register_admin_page']);
add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
// Frontend hooks
add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
// MDS integration
add_action('mds_register_dashboard_menu', [$this, 'register_dashboard_menu_items']);
// Register shortcode
add_shortcode('my_extension', [$this, 'shortcode_callback']);
}
public function load_textdomain(): void {
load_plugin_textdomain(
'my-mds-extension',
false,
dirname(MY_EXT_BASENAME) . '/languages'
);
}
public function register_admin_page(): void {
add_submenu_page(
'milliondollarscript', // Parent slug
__('My Extension', 'my-mds-extension'), // Page title
__('My Extension', 'my-mds-extension'), // Menu title
'manage_options', // Capability
'my-extension', // Menu slug
[$this, 'render_admin_page'] // Callback
);
}
public function register_dashboard_menu_items(string $registry_class): void {
$registry_class::register([
'slug' => 'my-extension',
'title' => __('My Extension', 'my-mds-extension'),
'url' => admin_url('admin.php?page=my-extension'),
'parent' => 'mds-extensions',
'position' => 10,
]);
}
public function render_admin_page(): void {
// Check permissions
if (!current_user_can('manage_options')) {
wp_die(__('You do not have permission to access this page.', 'my-mds-extension'));
}
?>
<div class="wrap">
<h1><?php echo esc_html(get_admin_page_title()); ?></h1>
<!-- Your admin content here -->
</div>
<?php
}
public function enqueue_admin_assets(string $hook): void {
// Only load on our admin page
if ('million-dollar-script_page_my-extension' !== $hook) {
return;
}
wp_enqueue_style(
'my-extension-admin',
MY_EXT_URL . 'assets/css/admin.css',
[],
MY_EXT_VERSION
);
wp_enqueue_script(
'my-extension-admin',
MY_EXT_URL . 'assets/js/admin.js',
['jquery'],
MY_EXT_VERSION,
true
);
}
public function enqueue_frontend_assets(): void {
wp_enqueue_style(
'my-extension-frontend',
MY_EXT_URL . 'assets/css/frontend.css',
[],
MY_EXT_VERSION
);
}
public function shortcode_callback(array $atts): string {
$atts = shortcode_atts([
'title' => __('Default Title', 'my-mds-extension'),
'option' => 'value',
], $atts, 'my_extension');
ob_start();
?>
<div class="my-extension-wrapper">
<h3><?php echo esc_html($atts['title']); ?></h3>
<!-- Shortcode output here -->
</div>
<?php
return ob_get_clean();
}
}
Adding to MDS Menu
The recommended way to add admin links is via the mds_register_dashboard_menu action and Menu_Registry:
add_action('mds_register_dashboard_menu', function (string $registry_class): void {
$registry_class::register([
'slug' => 'my-extension',
'title' => __('My Extension', 'my-mds-extension'),
'url' => admin_url('admin.php?page=my-extension'),
'parent' => 'mds-extensions',
'position' => 10,
]);
});
Use 'parent' => 'mds-extensions' so your item appears under the Extensions dropdown. This keeps extensions organized and doesn't clutter the WordPress sidebar. See Hooks Reference for all available parent slugs.
Using Carbon Fields
MDS uses Carbon Fields for options. You can add your own options:
use Carbon_Fields\Container;
use Carbon_Fields\Field;
add_action('carbon_fields_register_fields', function () {
Container::make('theme_options', __('My Extension Settings', 'my-mds-extension'))
->set_page_parent('milliondollarscript')
->add_fields([
Field::make('text', 'my_ext_api_key', __('API Key', 'my-mds-extension'))
->set_help_text(__('Enter your API key here.', 'my-mds-extension')),
Field::make('checkbox', 'my_ext_enabled', __('Enable Feature', 'my-mds-extension'))
->set_option_value('yes'),
Field::make('select', 'my_ext_mode', __('Mode', 'my-mds-extension'))
->add_options([
'basic' => __('Basic', 'my-mds-extension'),
'advanced' => __('Advanced', 'my-mds-extension'),
]),
]);
});
// Retrieve option values
$api_key = carbon_get_theme_option('my_ext_api_key');
$enabled = carbon_get_theme_option('my_ext_enabled');
Creating Shortcodes
public function __construct() {
add_shortcode('my_extension', [$this, 'shortcode_callback']);
}
public function shortcode_callback($atts): string {
$atts = shortcode_atts([
'title' => __('Default Title', 'my-mds-extension'),
'show_option' => 'true',
'class' => '',
], $atts, 'my_extension');
// Sanitize attributes
$show_option = filter_var($atts['show_option'], FILTER_VALIDATE_BOOLEAN);
$class = sanitize_html_class($atts['class']);
ob_start();
?>
<div class="my-extension <?php echo esc_attr($class); ?>">
<h3><?php echo esc_html($atts['title']); ?></h3>
<?php if ($show_option) : ?>
<p><?php esc_html_e('Option is enabled', 'my-mds-extension'); ?></p>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
AJAX Handlers
// Register AJAX actions
add_action('wp_ajax_my_extension_action', [$this, 'handle_ajax']);
add_action('wp_ajax_nopriv_my_extension_action', [$this, 'handle_ajax']); // For non-logged-in users
public function handle_ajax(): void {
// Verify nonce
if (!check_ajax_referer('my_extension_nonce', 'nonce', false)) {
wp_send_json_error(['message' => __('Security check failed.', 'my-mds-extension')]);
}
// Check permissions if needed
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => __('Permission denied.', 'my-mds-extension')]);
}
// Process the request
$data = isset($_POST['data']) ? sanitize_text_field($_POST['data']) : '';
// Return response
wp_send_json_success([
'message' => __('Success!', 'my-mds-extension'),
'data' => $data,
]);
}
Testing
The skeleton includes PHPUnit setup with Brain Monkey for WordPress mocks:
# Install dependencies
composer install
# Run tests
composer test
# or
./vendor/bin/phpunit
Example test:
<?php
namespace My_Extension\Tests;
use PHPUnit\Framework\TestCase;
use Brain\Monkey;
use Brain\Monkey\Functions;
class PluginTest extends TestCase {
protected function setUp(): void {
parent::setUp();
Monkey\setUp();
}
protected function tearDown(): void {
Monkey\tearDown();
parent::tearDown();
}
public function test_shortcode_returns_html(): void {
Functions\when('shortcode_atts')->returnArg(1);
Functions\when('esc_html')->returnArg(1);
Functions\when('__')->returnArg(1);
$plugin = new \My_Extension\Plugin();
$output = $plugin->shortcode_callback(['title' => 'Test']);
$this->assertStringContainsString('Test', $output);
}
}
Activation and Deactivation
Handle plugin lifecycle events:
// In main plugin file
register_activation_hook(__FILE__, [My_Extension\Plugin::class, 'activate']);
register_deactivation_hook(__FILE__, [My_Extension\Plugin::class, 'deactivate']);
// In Plugin class
public static function activate(): void {
// Create database tables
// Set default options
// Flush rewrite rules if registering custom post types
flush_rewrite_rules();
}
public static function deactivate(): void {
// Clean up scheduled events
wp_clear_scheduled_hook('my_extension_cron');
// Flush rewrite rules
flush_rewrite_rules();
}
Uninstall Cleanup
Create uninstall.php in your plugin root:
<?php
// Exit if not called by WordPress
if (!defined('WP_UNINSTALL_PLUGIN')) {
exit;
}
// Delete options
delete_option('my_extension_settings');
// Delete custom tables (if any)
global $wpdb;
$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}my_extension_data");
// Delete user meta (if any)
delete_metadata('user', 0, 'my_extension_preference', '', true);
Release Checklist
Before releasing your extension:
- Unique text domain that won't conflict with other plugins
- All strings wrapped in translation functions
- Proper capability checks on all admin functions
- Nonce verification on all form submissions
- Input sanitization on all user data
- Output escaping on all rendered content
- Activation/deactivation hooks handle setup/cleanup
- Uninstall.php removes all plugin data
- README.md with installation and usage instructions
- Changelog documenting all versions
- Tests passing
- Code follows WordPress Coding Standards
- Tested with latest WordPress and PHP versions
- Tested with latest MDS version
Resources
- Hooks Reference - Available MDS hooks
- List Page Customization - Extending the advertiser list
- WordPress Plugin Handbook
- Carbon Fields Documentation