Skip to content

Add-on Development Guide

This comprehensive guide covers everything you need to know to build add-on plugins for Virtual Media Folders.

Virtual Media Folders uses a virtual folder approach that’s fundamentally different from traditional file-based organization:

  1. Files Never Move – Media files stay exactly where WordPress uploaded them. The physical file location and URL never change when you “move” media between folders.

  2. Folders Are Taxonomy Terms – Folders are implemented as terms in a custom hierarchical taxonomy (vmfo_folder). This leverages WordPress’s mature term system for relationships, hierarchy, and querying.

  3. One Folder Per Item – Each media attachment belongs to zero or one folder at a time (single-term assignment), mimicking traditional file system behavior.

  4. Non-Destructive – Deleting a folder only removes the organizational structure. The media files themselves remain in the library.

  • URL Stability – Embedded images and links never break when reorganizing
  • Performance – No file I/O operations when moving media
  • Reversibility – Easy to undo or reorganize without consequences
  • WordPress Native – Uses standard taxonomy APIs that themes and plugins understand
Media Attachment (post_type: attachment)
└── vmfo_folder (taxonomy term relationship)
└── Term: "Photos" (term_id: 5, parent: 0)
└── Term: "Events" (term_id: 12, parent: 5)

When you “move” a file to a folder, the plugin simply calls:

wp_set_object_terms( $attachment_id, $folder_term_id, 'vmfo_folder' );

This philosophy should guide your add-on development: work with taxonomy terms, not file operations.

Virtual Media Folders is designed to be extensible. Add-ons can:

  • Register settings tabs within the parent plugin’s settings page
  • Use the folder taxonomy (vmfo_folder) to organize media
  • Hook into media upload and folder assignment events
  • Extend the REST API for custom functionality
  • Integrate with the folder sidebar in both Media Library and Gutenberg

Five official add-ons are available as reference implementations:

  • AI Organizer – Uses AI vision models to automatically suggest folders for images
  • Editorial Workflow – Role-based folder access, move restrictions, and Inbox workflow
  • Folder Exporter – Export folders as ZIP archives with optional CSV manifests
  • Media Cleanup – Tools to identify and clean up unused or duplicate media files
  • Rules Engine – Rule-based automatic folder assignment based on metadata
  • Virtual Media Folders 2.0.0 or later (for base classes; 1.6.0 minimum for raw hook integration)
  • PHP 8.3 or later
  • WordPress 6.8 or later

A typical add-on follows this structure:

my-vmfa-addon/
├── build/ # Compiled assets
├── src/
│ ├── php/ # PHP classes
│ │ ├── Plugin.php # Main plugin class (extends AbstractPlugin)
│ │ ├── Admin/
│ │ │ └── SettingsTab.php # Settings tab (extends AbstractSettingsTab)
│ │ └── REST/ # REST API controllers
│ ├── js/ # React components
│ │ ├── index.js # Entry point
│ │ ├── settings/ # Settings page components
│ │ │ ├── index.jsx
│ │ │ ├── SettingsPanel.jsx
│ │ │ └── StatsCard.jsx
│ │ └── components/ # Shared React components
│ └── styles/ # Stylesheets
│ └── settings.scss
├── languages/ # Translation files
├── my-vmfa-addon.php # Plugin bootstrap
├── package.json
├── composer.json
└── webpack.config.js

VMF core provides three base classes in the VirtualMediaFolders\Addon namespace that eliminate boilerplate from add-on plugins. All official add-ons use these base classes.

VirtualMediaFolders\Addon\AbstractPlugin — Singleton lifecycle, text domain loading, and parent-tab detection.

Abstract methods (required):

MethodReturnPurpose
get_text_domain()stringPlugin text domain, e.g. 'vmfa-rules-engine'
get_plugin_file()stringAbsolute path to the main .php file (typically a constant like VMFA_RULES_ENGINE_FILE)

Template methods (override as needed):

MethodDefaultPurpose
init_services()no-opCreate service objects and the SettingsTab instance
init_hooks()no-opRegister WordPress hooks (admin, REST, filters)
init_cli()no-opRegister WP-CLI commands

Inherited concrete methods:

MethodPurpose
get_instance(): staticPer-subclass singleton accessor
init(): voidBoot sequence — calls init_services(), init_hooks(), init_cli(), schedules load_textdomain
load_textdomain(): voidLoads languages/{text-domain}-{locale}.mo
supports_parent_tabs(): boolChecks VirtualMediaFolders\Settings::SUPPORTS_ADDON_TABS

Minimal example:

<?php
declare(strict_types=1);
namespace MyVmfaAddon;
use VirtualMediaFolders\Addon\AbstractPlugin;
final class Plugin extends AbstractPlugin {
protected function get_text_domain(): string {
return 'my-vmfa-addon';
}
protected function get_plugin_file(): string {
return MY_VMFA_ADDON_FILE;
}
protected function init_services(): void {
// Create your service objects here.
}
protected function init_hooks(): void {
// Register WordPress hooks here.
}
}

VirtualMediaFolders\Addon\AbstractSettingsTab — Tab registration, asset enqueue (JS + CSS + WP 7 compat), wp_localize_script, fallback standalone menu, and default React mount-point.

Abstract methods (required):

MethodReturnPurpose
get_tab_slug()stringTab slug, e.g. 'rules-engine'
get_tab_label()stringTranslated tab label
get_text_domain()stringPlugin text domain
get_build_path()stringAbsolute path to build/ (trailing slash)
get_build_url()stringURL to build/ (trailing slash)
get_languages_path()stringAbsolute path to languages/ directory
get_plugin_version()stringPlugin version (fallback for asset versioning)
get_localized_data()arrayData for wp_localize_script
get_localized_name()stringJS global variable name for localized data

Optional overrides:

MethodDefaultPurpose
get_asset_entry()'index'Build entry-point basename (e.g. 'settings')
get_style_deps()['wp-components']CSS dependencies
get_tab_definition(){title, callback}Override to add 'subtabs'
get_menu_capability()'manage_options'Capability for fallback menu
get_app_container_id()'vmfa-{slug}-app'React mount-point div id

Public API (called by Plugin’s init_hooks()):

MethodUsed as
register_tab( $tabs )vmfo_settings_tabs filter callback
enqueue_tab_scripts( $tab, $subtab )vmfo_settings_enqueue_scripts action callback
render_tab( $tab, $subtab )Tab render callback
register_admin_menu()admin_menu action callback (fallback)
enqueue_admin_assets( $hook )admin_enqueue_scripts action callback (fallback)
render_admin_page()Fallback standalone page render

The base class handles:

  • Loading build/{entry}.asset.php for dependency/version info
  • Enqueuing JS, CSS, and script translations
  • WP 7+ design-token compat CSS (shared wp7-compat-base.css from VMF core + plugin-specific build/wp7-compat.css)
  • Fallback standalone admin menu under Media when the parent plugin doesn’t support tabs

Minimal example:

<?php
declare(strict_types=1);
namespace MyVmfaAddon\Admin;
use VirtualMediaFolders\Addon\AbstractSettingsTab;
class SettingsTab extends AbstractSettingsTab {
protected function get_tab_slug(): string {
return 'my-addon';
}
protected function get_tab_label(): string {
return __( 'My Add-on', 'my-vmfa-addon' );
}
protected function get_text_domain(): string {
return 'my-vmfa-addon';
}
protected function get_build_path(): string {
return MY_VMFA_ADDON_PATH . 'build/';
}
protected function get_build_url(): string {
return MY_VMFA_ADDON_URL . 'build/';
}
protected function get_languages_path(): string {
return MY_VMFA_ADDON_PATH . 'languages';
}
protected function get_plugin_version(): string {
return MY_VMFA_ADDON_VERSION;
}
protected function get_localized_name(): string {
return 'myVmfaAddon';
}
protected function get_localized_data(): array {
return [
'restUrl' => rest_url( 'my-addon/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'folders' => $this->get_folders(), // your method
];
}
}

With subtabs:

protected function get_tab_definition(): array {
return [
'title' => $this->get_tab_label(),
'callback' => [ $this, 'render_tab' ],
'subtabs' => [
'scan' => __( 'Scan', 'my-vmfa-addon' ),
'results' => __( 'Results', 'my-vmfa-addon' ),
'settings' => __( 'Settings', 'my-vmfa-addon' ),
],
];
}

VirtualMediaFolders\Addon\ActionSchedulerLoader — Static helper to load Action Scheduler from an add-on’s vendor directory.

use VirtualMediaFolders\Addon\ActionSchedulerLoader;
// In the main plugin file, before plugins_loaded:
ActionSchedulerLoader::maybe_load( MY_VMFA_ADDON_PATH );

maybe_load( string $plugin_dir ): bool probes two paths:

  1. {$plugin_dir}vendor/woocommerce/action-scheduler/action-scheduler.php
  2. {$plugin_dir}woocommerce/action-scheduler/action-scheduler.php

Returns true if as_schedule_single_action is available (already loaded or just loaded). Safe to call from multiple add-ons — Action Scheduler’s internal version registry (ActionScheduler_Versions) ensures only the highest version boots.

In your Plugin class, create the SettingsTab in init_services() and wire the hooks in init_hooks():

protected function init_services(): void {
$this->settings_tab = new Admin\SettingsTab();
}
protected function init_hooks(): void {
if ( is_admin() ) {
if ( $this->supports_parent_tabs() ) {
add_filter( 'vmfo_settings_tabs', [ $this->settings_tab, 'register_tab' ] );
add_action( 'vmfo_settings_enqueue_scripts', [ $this->settings_tab, 'enqueue_tab_scripts' ], 10, 2 );
} else {
add_action( 'admin_menu', [ $this->settings_tab, 'register_admin_menu' ] );
add_action( 'admin_enqueue_scripts', [ $this->settings_tab, 'enqueue_admin_assets' ] );
}
}
add_action( 'rest_api_init', [ $this, 'register_rest_routes' ] );
}

The main plugin file defines constants, loads the autoloader, optionally loads Action Scheduler, and boots the Plugin singleton:

<?php
/**
* Plugin Name: My VMFA Add-on
* Description: Description of your add-on
* Version: 1.0.0
* Requires at least: 6.8
* Requires PHP: 8.3
* Requires Plugins: virtual-media-folders
* Author: Your Name
* License: GPL-2.0-or-later
* Text Domain: my-vmfa-addon
* Domain Path: /languages
*/
declare(strict_types=1);
namespace MyVmfaAddon;
defined( 'ABSPATH' ) || exit;
// Plugin constants.
define( 'MY_VMFA_ADDON_VERSION', '1.0.0' );
define( 'MY_VMFA_ADDON_FILE', __FILE__ );
define( 'MY_VMFA_ADDON_PATH', plugin_dir_path( __FILE__ ) );
define( 'MY_VMFA_ADDON_URL', plugin_dir_url( __FILE__ ) );
// Composer autoload.
if ( file_exists( MY_VMFA_ADDON_PATH . 'vendor/autoload.php' ) ) {
require_once MY_VMFA_ADDON_PATH . 'vendor/autoload.php';
}
// (Optional) Load Action Scheduler early — only if your add-on uses it.
use VirtualMediaFolders\Addon\ActionSchedulerLoader;
ActionSchedulerLoader::maybe_load( MY_VMFA_ADDON_PATH );
/**
* Initialize the plugin.
*/
function init(): void {
Plugin::get_instance()->init();
}
add_action( 'plugins_loaded', __NAMESPACE__ . '\\init', 20 );

Note: The Requires Plugins: virtual-media-folders header (WordPress 6.5+) ensures VMF is loaded first. For older WordPress, add a fallback check for defined( 'VMFO_VERSION' ) inside init().

  • SingletonPlugin::get_instance() returns a per-subclass singleton; private constructor, __clone, __wakeup are handled.
  • Text domainload_textdomain() is called on the init hook automatically.
  • supports_parent_tabs() — Checks the VMF parent constant; no need to duplicate this in every add-on.

VMF provides a tab-based settings page. Add-ons register their own tabs within the parent plugin’s “Folder Settings” page.

The simplest approach is to extend AbstractSettingsTab (see Base Classes above). The base class handles tab registration, asset enqueuing, WP 7 compat CSS, wp_localize_script, and the fallback standalone menu — all from a set of abstract getters.

Your Plugin class wires the SettingsTab to the correct hooks:

protected function init_hooks(): void {
if ( is_admin() ) {
if ( $this->supports_parent_tabs() ) {
add_filter( 'vmfo_settings_tabs', [ $this->settings_tab, 'register_tab' ] );
add_action( 'vmfo_settings_enqueue_scripts', [ $this->settings_tab, 'enqueue_tab_scripts' ], 10, 2 );
} else {
add_action( 'admin_menu', [ $this->settings_tab, 'register_admin_menu' ] );
add_action( 'admin_enqueue_scripts', [ $this->settings_tab, 'enqueue_admin_assets' ] );
}
}
}

That’s it — no inline do_enqueue_assets(), no WP 7 compat logic, no fallback menu boilerplate.

If you prefer not to use the base class, you can wire the hooks directly.

Check if the parent plugin supports the tab system:

private function supports_parent_tabs(): bool {
return defined( 'VirtualMediaFolders\Settings::SUPPORTS_ADDON_TABS' )
&& \VirtualMediaFolders\Settings::SUPPORTS_ADDON_TABS;
}

Use the vmfo_settings_tabs filter to register your add-on’s tab:

add_filter( 'vmfo_settings_tabs', function( array $tabs ): array {
$tabs['my-addon'] = [
'title' => __( 'My Add-on', 'my-vmfa-addon' ),
'callback' => [ $this, 'render_tab_content' ],
];
return $tabs;
});

Note: Tabs are automatically sorted alphabetically by title. The “General” tab always appears first, followed by add-on tabs in alphabetical order.

KeyTypeRequiredDescription
titlestringYesThe tab label displayed in the navigation
callbackcallableYesFunction to render the tab content. Receives $active_tab and $active_subtab as parameters.
subtabsarrayNoOptional secondary navigation: [ 'slug' => 'Title', ... ]

The callback function receives two parameters:

public function render_tab_content( string $active_tab, string $active_subtab ): void {
?>
<div id="my-addon-app"></div>
<?php
}

If your add-on has multiple sections, you can register subtabs for secondary navigation. The parent plugin renders the subtab bar automatically.

add_filter( 'vmfo_settings_tabs', function( array $tabs ): array {
$tabs['my-addon'] = [
'title' => __( 'My Add-on', 'my-vmfa-addon' ),
'callback' => [ $this, 'render_tab_content' ],
'subtabs' => [
'scanner' => __( 'Scanner', 'my-vmfa-addon' ),
'settings' => __( 'Settings', 'my-vmfa-addon' ),
'logs' => __( 'Logs', 'my-vmfa-addon' ),
],
];
return $tabs;
});

When subtabs are registered:

  • The parent plugin renders a secondary navigation bar below the main tabs
  • The first subtab is selected by default if none specified in URL
  • Your callback receives $active_subtab with the current selection
public function render_tab_content( string $active_tab, string $active_subtab ): void {
// Render content based on active subtab.
switch ( $active_subtab ) {
case 'scanner':
$this->render_scanner_content();
break;
case 'settings':
$this->render_settings_content();
break;
case 'logs':
$this->render_logs_content();
break;
}
}

For simpler add-ons, subtabs are optional. Just register a tab without the subtabs array:

$tabs['my-addon'] = [
'title' => __( 'My Add-on', 'my-vmfa-addon' ),
'callback' => [ $this, 'render_tab_content' ],
];

Render a container element and mount your React app:

public function render_tab_content( string $active_tab, string $active_subtab ): void {
// Pass the active subtab to React via data attribute or localized script.
?>
<div id="my-addon-app" data-subtab="<?php echo esc_attr( $active_subtab ); ?>"></div>
<?php
}

The settings page uses the following URL pattern:

/wp-admin/upload.php?page=vmfo-settings&tab={tab-slug}&subtab={subtab-slug}
  • page: Always vmfo-settings
  • tab: Your add-on’s tab slug (e.g., my-addon)
  • subtab: Your subtab slug (if using subtabs)

Only enqueue when your tab is active:

add_action( 'vmfo_settings_enqueue_scripts', function( string $active_tab, string $active_subtab ): void {
if ( 'my-addon' !== $active_tab ) {
return;
}
$asset_file = MY_ADDON_PATH . 'build/index.asset.php';
if ( ! file_exists( $asset_file ) ) {
return;
}
$asset = require $asset_file;
wp_enqueue_script(
'my-addon-admin',
MY_ADDON_URL . 'build/index.js',
$asset['dependencies'],
$asset['version'],
true
);
wp_enqueue_style(
'my-addon-admin',
MY_ADDON_URL . 'build/index.css',
[ 'wp-components' ],
$asset['version']
);
wp_localize_script( 'my-addon-admin', 'myAddonData', [
'restUrl' => rest_url( 'my-addon/v1/' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'activeSubtab' => $active_subtab,
'folders' => $this->get_folders(),
]);
}, 10, 2);

Backwards Compatibility (handled by AbstractSettingsTab)

Section titled “Backwards Compatibility (handled by AbstractSettingsTab)”

When using AbstractSettingsTab, backwards compatibility is built in. The Plugin class’s init_hooks() already branches between tab mode and standalone fallback mode (see above). If you’re wiring hooks manually, the same pattern applies:

public function init_admin(): void {
if ( $this->supports_parent_tabs() ) {
add_filter( 'vmfo_settings_tabs', [ $this, 'register_tab' ] );
add_action( 'vmfo_settings_enqueue_scripts', [ $this, 'enqueue_scripts' ], 10, 2 );
} else {
// Fallback to standalone menu.
add_action( 'admin_menu', [ $this, 'add_standalone_menu' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_standalone_scripts' ] );
}
}
public function add_standalone_menu(): void {
add_submenu_page(
'upload.php',
__( 'My Add-on Settings', 'my-addon' ),
__( 'My Add-on', 'my-addon' ),
'manage_options',
'my-addon-settings',
[ $this, 'render_standalone_page' ]
);
}
public function render_standalone_page(): void {
?>
<div class="wrap">
<h1><?php esc_html_e( 'My Add-on', 'my-addon' ); ?></h1>
<div id="my-addon-app"></div>
</div>
<?php
}
public function enqueue_standalone_scripts( string $hook_suffix ): void {
if ( 'media_page_my-addon-settings' !== $hook_suffix ) {
return;
}
// Enqueue your assets here.
}

If your add-on needs background processing (batch jobs, scheduled scans, etc.), use Action Scheduler via Composer:

Terminal window
composer require woocommerce/action-scheduler

Load it in your main plugin file before plugins_loaded using the shared helper:

use VirtualMediaFolders\Addon\ActionSchedulerLoader;
ActionSchedulerLoader::maybe_load( MY_VMFA_ADDON_PATH );

This is safe to call from multiple add-ons. Action Scheduler’s internal ActionScheduler_Versions registry ensures only the highest version boots. See ActionSchedulerLoader for details.

Folders use a custom taxonomy: vmfo_folder

// Get all folders.
$folders = get_terms([
'taxonomy' => 'vmfo_folder',
'hide_empty' => false,
'orderby' => 'meta_value_num',
'meta_key' => 'vmfo_order',
'order' => 'ASC',
]);
// Create a folder.
$result = wp_insert_term( 'My Folder', 'vmfo_folder', [
'parent' => 0, // 0 for root level
]);
// Assign media to a folder.
wp_set_object_terms( $attachment_id, $folder_id, 'vmfo_folder' );
// Get folder for a media item.
$folders = wp_get_object_terms( $attachment_id, 'vmfo_folder' );
// Remove media from all folders.
wp_set_object_terms( $attachment_id, [], 'vmfo_folder' );

Folders have a custom order stored in term meta:

// Get folder order.
$order = get_term_meta( $term_id, 'vmfo_order', true );
// Set folder order.
update_term_meta( $term_id, 'vmfo_order', 5 );

The parent plugin provides REST API endpoints under /wp-json/vmfo/v1:

MethodEndpointDescription
GET/foldersList all folders
POST/foldersCreate a folder
GET/folders/{id}Get a folder
PUT/folders/{id}Update a folder
DELETE/folders/{id}Delete a folder
GET/folders/{id}/can-deleteCheck if a folder can be deleted
POST/folders/{id}/mediaAdd media to folder
DELETE/folders/{id}/mediaRemove media from folder
POST/folders/reorderReorder folders
GET/folders/countsGet folder counts (supports media_type filter)
add_action( 'rest_api_init', function() {
register_rest_route( 'my-addon/v1', '/process', [
'methods' => 'POST',
'callback' => [ $this, 'process_media' ],
'permission_callback' => function() {
return current_user_can( 'upload_files' );
},
'args' => [
'attachment_id' => [
'required' => true,
'type' => 'integer',
'sanitize_callback' => 'absint',
],
],
]);
});

Provide a /stats endpoint for statistics cards:

register_rest_route(
'my-addon/v1',
'/stats',
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_stats' ],
'permission_callback' => [ $this, 'check_admin_permission' ],
]
);
public function get_stats( WP_REST_Request $request ): WP_REST_Response {
return rest_ensure_response( [
'totalMedia' => wp_count_posts( 'attachment' )->inherit,
'processed' => $this->get_processed_count(),
'pending' => $this->get_pending_count(),
'activeRules' => $this->get_active_rules_count(),
] );
}
register_rest_route(
'my-addon/v1',
'/settings',
[
[
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_settings' ],
'permission_callback' => [ $this, 'check_admin_permission' ],
],
[
'methods' => WP_REST_Server::CREATABLE,
'callback' => [ $this, 'update_settings' ],
'permission_callback' => [ $this, 'check_admin_permission' ],
],
]
);
// Modify default settings.
add_filter( 'vmfo_default_settings', function( $defaults ) {
$defaults['my_option'] = true;
return $defaults;
});
// Filter all settings.
add_filter( 'vmfo_settings', function( $options ) {
// Modify options.
return $options;
});
// Filter a specific setting.
add_filter( 'vmfo_setting_default_folder', function( $value, $key, $options ) {
// Return modified value.
return $value;
}, 10, 3);
// Enqueue scripts on settings page.
add_action( 'vmfo_settings_enqueue_scripts', function( $active_tab, $active_subtab ) {
// Enqueue your assets.
}, 10, 2);
// Register settings tabs.
add_filter( 'vmfo_settings_tabs', function( $tabs ) {
$tabs['my-addon'] = [
'title' => __( 'My Add-on', 'my-addon' ),
'callback' => [ $this, 'render_content' ],
];
return $tabs;
});
// Fired after media is assigned to a folder.
add_action( 'vmfo_folder_assigned', function( $attachment_id, $folder_id, $result ) {
// Handle the folder assignment.
// $result contains the return value from wp_set_object_terms.
}, 10, 3);
// Prevent folder deletion (e.g., if folder has rules).
add_filter( 'vmfo_can_delete_folder', function( $can_delete, $folder_id, $term ) {
// Check if folder has associated rules.
$has_rules = get_term_meta( $folder_id, 'my_addon_has_rules', true );
if ( $has_rules ) {
return new WP_Error(
'folder_has_rules',
__( 'Cannot delete folder: it has active rules. Remove the rules first.', 'my-addon' ),
[ 'status' => 400 ]
);
}
return $can_delete;
}, 10, 3);
// Include child folder media when querying a parent folder.
add_filter( 'vmfo_include_child_folders', function( $include, $folder_id ) {
// Return true to include media from child folders.
return $include;
}, 10, 2);

Process media on upload:

add_filter( 'wp_generate_attachment_metadata', function( $metadata, $attachment_id, $context ) {
// Only process on new uploads.
if ( 'create' !== $context ) {
return $metadata;
}
// Get attachment data.
$attachment = get_post( $attachment_id );
$file_path = get_attached_file( $attachment_id );
$mime_type = get_post_mime_type( $attachment_id );
// Your processing logic here.
// ...
// Assign to a folder.
wp_set_object_terms( $attachment_id, $folder_id, 'vmfo_folder' );
return $metadata;
}, 20, 3); // Priority 20 to run after VMFO
HookParametersDescription
vmfo_settings_tabsarray $tabsRegister add-on tabs
vmfo_default_settingsarray $defaultsModify default settings
vmfo_settingsarray $optionsFilter all settings
vmfo_setting_{$key}mixed $value, string $key, array $optionsFilter a specific setting
vmfo_include_child_foldersbool $include, int $folder_idInclude child folder media in queries
vmfo_can_delete_folder`boolWP_Error $can_delete, int $folder_id, WP_Term $term`
HookParametersDescription
vmfo_settings_enqueue_scriptsstring $active_tab, string $active_subtabEnqueue tab-specific scripts
vmfo_folder_assignedint $attachment_id, int $folder_id, array $resultFired after media is assigned to a folder

Use @wordpress/scripts for consistency with WordPress:

{
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start",
"lint:js": "wp-scripts lint-js",
"lint:css": "wp-scripts lint-style"
},
"devDependencies": {
"@wordpress/scripts": "^30.0.0"
}
}

For multiple entry points:

const defaultConfig = require('@wordpress/scripts/config/webpack.config');
const path = require('path');
module.exports = {
...defaultConfig,
entry: {
settings: path.resolve( __dirname, 'src/js/settings/index.jsx' ),
// Add other entry points as needed
},
output: {
...defaultConfig.output,
path: path.resolve( __dirname, 'build' ),
},
};
import { useState, useEffect } from '@wordpress/element';
import { Button, Modal, TextControl, SelectControl } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
function MyAddonApp() {
const [folders, setFolders] = useState([]);
useEffect(() => {
apiFetch({ path: '/vmfo/v1/folders' }).then(setFolders);
}, []);
return (
<div className="my-addon-container">
<h2>{__('My Add-on Settings', 'my-addon')}</h2>
<SelectControl
label={__('Select Folder', 'my-addon')}
options={folders.map(f => ({ label: f.name, value: f.id }))}
/>
</div>
);
}
import { createRoot } from '@wordpress/element';
import MyAddonApp from './components/MyAddonApp';
document.addEventListener('DOMContentLoaded', () => {
const container = document.getElementById('my-addon-app');
if (container) {
createRoot(container).render(<MyAddonApp />);
}
});
import { useState, useEffect, useCallback } from '@wordpress/element';
import { Button, Spinner, Notice } from '@wordpress/components';
import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';
export default function SettingsPanel() {
const [ isLoading, setIsLoading ] = useState( true );
const [ isSaving, setIsSaving ] = useState( false );
const [ notice, setNotice ] = useState( null );
const [ settings, setSettings ] = useState( {} );
const [ stats, setStats ] = useState( {} );
useEffect( () => {
fetchSettings();
fetchStats();
}, [] );
const fetchSettings = useCallback( async () => {
setIsLoading( true );
try {
const response = await apiFetch( { path: '/my-addon/v1/settings' } );
setSettings( response );
} catch ( error ) {
setNotice( { status: 'error', message: error.message } );
} finally {
setIsLoading( false );
}
}, [] );
const saveSettings = useCallback( async () => {
setIsSaving( true );
setNotice( null );
try {
await apiFetch( {
path: '/my-addon/v1/settings',
method: 'POST',
data: settings,
} );
setNotice( { status: 'success', message: __( 'Settings saved.', 'my-addon' ) } );
} catch ( error ) {
setNotice( { status: 'error', message: error.message } );
} finally {
setIsSaving( false );
}
}, [ settings ] );
if ( isLoading ) {
return (
<div className="my-addon-loading">
<Spinner />
<p>{ __( 'Loading…', 'my-addon' ) }</p>
</div>
);
}
return (
<div className="my-addon-settings-panel">
{ notice && (
<Notice
status={ notice.status }
isDismissible
onRemove={ () => setNotice( null ) }
>
{ notice.message }
</Notice>
) }
{/* Your settings cards here */}
<div className="my-addon-actions">
<Button
variant="primary"
onClick={ saveSettings }
isBusy={ isSaving }
disabled={ isSaving }
>
{ isSaving ? __( 'Saving…', 'my-addon' ) : __( 'Save Changes', 'my-addon' ) }
</Button>
</div>
</div>
);
}

VMF add-ons should provide a consistent, modern user experience that integrates seamlessly with the WordPress admin.

Add-ons appear as tabs within the Virtual Media Folders settings page:

┌─────────────────────────────────────────────────────────────────┐
│ Virtual Media Folders Settings │
├─────────┬─────────────────┬──────────────┬─────────────┬────────┤
│ General │ AI Organizer │ Edit. Workfl │ Media Clean │ ... │ ← Top-level tabs
└─────────┴─────────────────┴──────────────┴─────────────┴────────┘
Scanner Settings AI Provider ← Sub-tabs (optional)
┌─────────────────────────────────────────────────────────────────┐
│ │
│ [Your add-on content] │
│ │
└─────────────────────────────────────────────────────────────────┘

Sub-tabs (when registered) appear as a secondary navigation bar directly below the main tabs.

Add-ons have complete control over their UI within their tab content area. You can:

  1. Simple add-on: Single settings form, no subtabs needed
  2. Multi-section add-on: Register subtabs that make sense for your features
  3. Complex add-on: Use React for interactive dashboards with statistics

Rules Engine (simple): Single tab with rule list and settings

$tabs['rules-engine'] = [
'title' => __( 'Rules', 'vmfa-rules-engine' ),
'callback' => [ $this, 'render_rules_panel' ],
];

AI Organizer (with subtabs): Scanner, Settings, and Provider sections

$tabs['ai-organizer'] = [
'title' => __( 'AI Organizer', 'vmfa-ai-organizer' ),
'callback' => [ $this, 'render_tab' ],
'subtabs' => [
'scanner' => __( 'Media Scanner', 'vmfa-ai-organizer' ),
'settings' => __( 'Settings', 'vmfa-ai-organizer' ),
'provider' => __( 'AI Provider', 'vmfa-ai-organizer' ),
],
];

Media Cleanup (many subtabs): Multiple cleanup category views

$tabs['media-cleanup'] = [
'title' => __( 'Media Cleanup', 'vmfa-media-cleanup' ),
'callback' => [ $this, 'render_tab' ],
'subtabs' => [
'scan' => __( 'Scan', 'vmfa-media-cleanup' ),
'unused' => __( 'Unused', 'vmfa-media-cleanup' ),
'duplicates' => __( 'Duplicates', 'vmfa-media-cleanup' ),
'oversized' => __( 'Oversized', 'vmfa-media-cleanup' ),
'settings' => __( 'Settings', 'vmfa-media-cleanup' ),
],
];

Use WordPress admin colors for consistency:

PurposeColorUsage
Primary text#1d2327Headings, important text
Secondary text#646970Descriptions, help text
Muted text#a7aaadDisabled, placeholders
Border#c3c4c7Card borders
Light border#f0f0f1Internal dividers
Background#f6f7f7Expandable headers, hover states
Primary action#2271b1Links, icons, primary buttons
Success#00a32aSuccess states, approved
Warning#dba617Warnings, pending
Error#d63638Errors, needs attention

Display key metrics at the top of your settings page using a 4-column grid:

┌─────────────────────────────────────────────────────────────────┐
│ │
│ 1002 │ 1002 │ 0 │ 217 │
│ Total Media │ In Folders │ Unassigned │ Folders │
│ │
└─────────────────────────────────────────────────────────────────┘

Example React component:

function StatsCard({ stats }) {
return (
<div className="my-addon-stats-card">
{stats.map((stat, index) => (
<div key={index} className="my-addon-stat-item">
<div className="my-addon-stat-value">{stat.value}</div>
<div className="my-addon-stat-label">{stat.label}</div>
</div>
))}
</div>
);
}
// Usage:
const stats = [
{ label: 'Total Media', value: 1002 },
{ label: 'In Folders', value: 1002 },
{ label: 'Unassigned', value: 0 },
{ label: 'Folders', value: 217 },
];
<StatsCard stats={stats} />

CSS:

.my-addon-stats-card {
display: grid;
grid-template-columns: repeat(4, 1fr);
padding: 24px 20px;
text-align: center;
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
margin-bottom: 20px;
}
.my-addon-stat-item {
padding: 0 16px;
border-right: 1px solid #f0f0f1;
}
.my-addon-stat-item:last-child {
border-right: none;
}
.my-addon-stat-value {
font-size: 32px;
font-weight: 600;
line-height: 1.2;
color: #1d2327;
}
.my-addon-stat-label {
font-size: 13px;
color: #646970;
margin-top: 4px;
}
@media (max-width: 782px) {
.my-addon-stats-card {
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.my-addon-stat-item {
border-right: none;
}
}

Use card containers for grouping related settings:

┌─────────────────────────────────────────────────────────────────┐
│ Section Title [Toggle] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Description text explaining this section. │
│ │
│ [Content area - forms, lists, etc.] │
│ │
└─────────────────────────────────────────────────────────────────┘

CSS:

.my-addon-card {
background: #fff;
border: 1px solid #c3c4c7;
border-radius: 4px;
margin-bottom: 20px;
}
.my-addon-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f1;
}
.my-addon-card-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1d2327;
}
.my-addon-card-body {
padding: 20px;
}
.my-addon-card-description {
margin: 0 0 20px;
color: #646970;
}

For complex data like permissions or rules:

.my-addon-expandable-header {
display: flex;
align-items: center;
width: 100%;
padding: 12px 16px;
background: #f6f7f7;
border: none;
cursor: pointer;
text-align: left;
font-size: 13px;
transition: background-color 0.15s;
}
.my-addon-expandable-header:hover {
background: #f0f0f1;
}
.my-addon-toggle-icon {
margin-right: 8px;
color: #50575e;
}
.my-addon-item-name {
flex: 1;
font-weight: 500;
color: #1d2327;
}
.my-addon-item-count {
font-size: 12px;
color: #646970;
}

Primary actions should use WordPress button patterns:

import { Button } from '@wordpress/components';
<div className="my-addon-actions">
<Button
variant="primary"
onClick={ handleSave }
isBusy={ isSaving }
disabled={ isSaving }
>
{ isSaving ? __( 'Saving…', 'my-addon' ) : __( 'Save Changes', 'my-addon' ) }
</Button>
</div>

CSS:

.my-addon-actions {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #dcdcde;
}

Use CSS Grid for multi-column form layouts:

.my-addon-field-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
/* Responsive auto-fill grid */
.my-addon-auto-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
@media (max-width: 782px) {
.my-addon-field-grid {
grid-template-columns: 1fr;
}
}
  1. Use semantic HTML - Use appropriate heading levels, lists, and buttons
  2. Keyboard navigation - Ensure all interactive elements are focusable
  3. ARIA attributes - Add aria-expanded for expandable sections
  4. Color contrast - Maintain WCAG AA compliance (4.5:1 for text)
  5. Focus indicators - Use WordPress default focus styles
<button
type="button"
className="my-addon-expandable-header"
onClick={ () => toggleSection( id ) }
aria-expanded={ isExpanded }
>
{/* content */}
</button>
__( 'My string', 'my-vmfa-addon' );
_e( 'My string', 'my-vmfa-addon' );
sprintf( __( 'Processed %d items', 'my-vmfa-addon' ), $count );
import { __, sprintf } from '@wordpress/i18n';
const label = __('My string', 'my-vmfa-addon');
const message = sprintf(__('Processed %d items', 'my-vmfa-addon'), count);
Terminal window
# Generate POT file.
wp i18n make-pot . languages/my-vmfa-addon.pot --domain=my-vmfa-addon
# Generate JSON for JavaScript.
wp i18n make-json languages/ --no-purge

JavaScript Translation Mapping (i18n-map.json)

Section titled “JavaScript Translation Mapping (i18n-map.json)”

When using @wordpress/scripts to bundle JavaScript, source files are combined into build output files. WordPress needs to know which build file contains translations from which source files. Create an i18n-map.json file to map source files to their build outputs:

{
"src/js/index.js": "build/index.js",
"src/js/components/MyComponent.jsx": "build/index.js",
"src/js/components/AnotherComponent.jsx": "build/index.js",
"src/js/admin.js": "build/admin.js"
}

Then use the map when generating JSON translation files:

Terminal window
wp i18n make-json languages/ --no-purge --use-map=i18n-map.json

Key points:

  • Only include files that contain translatable strings (__(), _x(), _n(), sprintf())
  • Map each source file to its corresponding bundled output file
  • Use --no-purge to keep existing JSON files when regenerating
  • The map ensures translations load correctly from the bundled scripts
<?php
use Brain\Monkey;
use PHPUnit\Framework\TestCase;
class MyAddonTest extends TestCase {
protected function setUp(): void {
parent::setUp();
Monkey\setUp();
}
protected function tearDown(): void {
Monkey\tearDown();
parent::tearDown();
}
public function test_example(): void {
// Your test.
}
}
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import MyComponent from '../components/MyComponent';
describe('MyComponent', () => {
it('renders correctly', () => {
render(<MyComponent />);
expect(screen.getByText('Expected text')).toBeInTheDocument();
});
});
Constant / ClassTypeDescription
VMFO_VERSIONstringParent plugin version
VMFO_PATHstringParent plugin path
VMFO_URLstringParent plugin URL
VirtualMediaFolders\Settings::SUPPORTS_ADDON_TABSboolTab system support
VirtualMediaFolders\Settings::PAGE_SLUGstringSettings page slug (vmfo-settings)
VirtualMediaFolders\Addon\AbstractPluginclassBase class for add-on Plugin
VirtualMediaFolders\Addon\AbstractSettingsTabclassBase class for add-on SettingsTab
VirtualMediaFolders\Addon\ActionSchedulerLoaderclassAction Scheduler loader helper
  1. Use the base classes — Extend AbstractPlugin and AbstractSettingsTab to eliminate boilerplate
  2. Declare dependency — Use the Requires Plugins: virtual-media-folders header (WordPress 6.5+)
  3. Check parent plugin — Also verify VMFO_VERSION is defined for older WordPress versions
  4. Use priorities — Hook into upload filters with priority 20+ to run after VMFO
  5. Namespace everything — Use unique prefixes for options, meta keys, and hooks
  6. Support fallbacks — Branch on supports_parent_tabs() for tab vs standalone menu
  7. Follow WordPress standards — Use WordPress Coding Standards and components
  8. Test thoroughly — Include both PHP and JavaScript tests
  9. Internationalize — Make all strings translatable

Before releasing your add-on, verify:

Tab Integration:

  • Tab registered via vmfo_settings_tabs filter
  • Tab title is descriptive and concise
  • Subtabs registered if add-on has multiple sections (optional)
  • Content renders correctly within the tab area

User Interface:

  • Loading states show spinner
  • Success/error notices display properly
  • UI is responsive on mobile (< 782px)
  • Form fields have proper labels
  • Save/action buttons are clearly labeled

Code Quality:

  • Plugin extends AbstractPlugin
  • SettingsTab extends AbstractSettingsTab
  • All text is translatable with __() or _e()
  • REST endpoints return proper error responses
  • Assets are properly enqueued only on your tab
  • No console errors or warnings
  • Fallback works if parent plugin not available