Skip to main content

Plugin Development

This document defines the company standard for structuring and architecting WordPress plugins. It is based on established patterns (including ThemeSwitcher Pro) and should be followed for all new plugin development.

The source export also contained theme-focused material; that content now lives under Theme Development so this page stays scoped to plugins.


Directory Structure

All plugins must follow this directory structure:

plugin-name/
├── plugin-name.php # Main plugin entry point
├── index.php # Security file ("Silence is golden")
├── composer.json # PHP dependencies and scripts
├── package.json # Node dependencies and build scripts
├── webpack.global.js # Webpack configuration
├── phpcs.xml.dist # PHP CodeSniffer configuration
├── .editorconfig # Editor configuration
├── .prettierrc.js # Prettier configuration
├── .stylelintrc.json # Stylelint configuration

├── admin/ # Admin-only code
│ ├── class-{prefix}-admin.php # Admin orchestrator class
│ ├── partials/ # Admin UI components
│ │ ├── templates/ # Admin template files
│ │ └── class-*.php # Admin partial classes
│ ├── css/ # Built admin CSS
│ └── js/ # Built admin JS

├── public/ # Frontend-only code
│ ├── class-{prefix}-public.php
│ ├── css/ # Built public CSS
│ └── js/ # Built public JS

├── inc/ # Core logic (shared between admin/public)
│ ├── class-{prefix}.php # Main plugin class
│ ├── class-{prefix}-loader.php # Hook registry
│ ├── class-{prefix}-i18n.php # Internationalization
│ ├── class-{prefix}-activator.php
│ ├── class-{prefix}-deactivator.php
│ ├── class-{prefix}-helper.php # Singleton helper utilities
│ ├── handler-classes/ # Request/data handlers
│ └── addons/ # Third-party integrations

├── assets/ # Source files (pre-build)
│ ├── js/ # Source JavaScript
│ └── scss/ # Source SCSS

├── languages/ # Translation files
│ └── plugin-name.pot # POT template

├── docs/ # Documentation
└── build/ # Build output (gitignored)

Security index files

Every directory must contain an index.php file with:

<?php
// Silence is golden.

This prevents directory listing on servers with directory browsing enabled.


PHP class organization

Naming conventions

ElementConventionExample
Class prefix2–4 letter uppercase abbreviationTSP_, ABC_
Class names{PREFIX}_{Descriptive_Name}TSP_Admin, TSP_Settings
File namesclass-{prefix}-{name}.phpclass-tsp-admin.php
Hook prefix{prefix}_filter_, {prefix}_action_tsp_filter_themes, tsp_action_init

Class loading

Classes are loaded via require_once or include_once. Load classes in the main plugin class's load_dependencies() method:

private function load_dependencies() {
include_once plugin_dir_path( __DIR__ ) . 'inc/class-{prefix}-loader.php';
include_once plugin_dir_path( __DIR__ ) . 'admin/class-{prefix}-admin.php';
include_once plugin_dir_path( __DIR__ ) . 'public/class-{prefix}-public.php';
// Additional dependencies...
}

DocBlocks

All classes must include proper DocBlocks:

<?php
/**
* The admin-specific functionality of the plugin.
*
* @package Plugin_Name
* @subpackage Plugin_Name/admin
* @author Company Name <email@example.com>
* @since 1.0.0
*/

/**
* Class {PREFIX}_Admin
*
* Handles all admin-side functionality.
*
* @since 1.0.0
*/
class PREFIX_Admin {
// ...
}

Singleton pattern

For utility/helper classes that should only be instantiated once:

class PREFIX_Helper {
private static $instance = null;

public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}

private function __construct() {
// Private constructor
}
}

Entry point and initialization

Main plugin file structure

The main plugin file (plugin-name.php) must follow this structure:

<?php
/**
* Plugin Name: Plugin Name
* Plugin URI: https://example.com/plugin
* Description: Brief description of the plugin.
* Version: 1.0.0
* Requires at least: 6.0
* Requires PHP: 8.0
* Author: Company Name
* Author URI: https://example.com
* License: GPL-2.0+
* License URI: http://www.gnu.org/licenses/gpl-2.0.txt
* Text Domain: plugin-name
* Domain Path: /languages
*/

// Abort if called directly.
if ( ! defined( 'WPINC' ) ) {
die;
}

// PHP version check.
if ( version_compare( PHP_VERSION, '8.0', '<' ) ) {
add_action( 'admin_notices', function() {
echo '<div class="error"><p>';
esc_html_e( 'Plugin Name requires PHP 8.0 or higher.', 'plugin-name' );
echo '</p></div>';
});
return;
}

// Define plugin constants.
define( 'PREFIX_VERSION', '1.0.0' );
define( 'PREFIX_FILE', __FILE__ );
define( 'PREFIX_DIR', plugin_dir_path( __FILE__ ) );
define( 'PREFIX_URL', plugin_dir_url( __FILE__ ) );

// Load dependencies.
require_once PREFIX_DIR . 'inc/class-prefix-activator.php';
require_once PREFIX_DIR . 'inc/class-prefix-deactivator.php';
require_once PREFIX_DIR . 'inc/class-prefix.php';

// Activation/Deactivation hooks.
register_activation_hook( __FILE__, [ 'PREFIX_Activator', 'activate' ] );
register_deactivation_hook( __FILE__, [ 'PREFIX_Deactivator', 'deactivate' ] );

/**
* Begin plugin execution.
*
* @since 1.0.0
*/
function run_prefix() {
$plugin = new PREFIX();
$plugin->run();
}
run_prefix();

Main plugin class

class PREFIX {
protected $loader;
protected $plugin_name;
protected $version;

public function __construct() {
$this->plugin_name = 'plugin-name';
$this->version = PREFIX_VERSION;

$this->load_dependencies();
$this->set_locale();
$this->define_admin_hooks();
$this->define_public_hooks();
}

private function load_dependencies() {
require_once PREFIX_DIR . 'inc/class-prefix-loader.php';
require_once PREFIX_DIR . 'inc/class-prefix-i18n.php';
require_once PREFIX_DIR . 'admin/class-prefix-admin.php';
require_once PREFIX_DIR . 'public/class-prefix-public.php';

$this->loader = new PREFIX_Loader();
}

private function set_locale() {
$i18n = new PREFIX_i18n();
$this->loader->add_action( 'plugins_loaded', $i18n, 'load_plugin_textdomain' );
}

private function define_admin_hooks() {
$admin = new PREFIX_Admin( $this->plugin_name, $this->version );
$this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_styles' );
$this->loader->add_action( 'admin_enqueue_scripts', $admin, 'enqueue_scripts' );
}

private function define_public_hooks() {
$public = new PREFIX_Public( $this->plugin_name, $this->version );
$this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_styles' );
$this->loader->add_action( 'wp_enqueue_scripts', $public, 'enqueue_scripts' );
}

public function run() {
$this->loader->run();
}
}

Hook loader class

Use a centralized hook loader for better organization:

class PREFIX_Loader {
protected $actions = [];
protected $filters = [];

public function add_action( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) {
$this->actions = $this->add( $this->actions, $hook, $component, $callback, $priority, $accepted_args );
}

public function add_filter( $hook, $component, $callback, $priority = 10, $accepted_args = 1 ) {
$this->filters = $this->add( $this->filters, $hook, $component, $callback, $priority, $accepted_args );
}

private function add( $hooks, $hook, $component, $callback, $priority, $accepted_args ) {
$hooks[] = [
'hook' => $hook,
'component' => $component,
'callback' => $callback,
'priority' => $priority,
'accepted_args' => $accepted_args,
];
return $hooks;
}

public function run() {
foreach ( $this->filters as $hook ) {
add_filter( $hook['hook'], [ $hook['component'], $hook['callback'] ], $hook['priority'], $hook['accepted_args'] );
}
foreach ( $this->actions as $hook ) {
add_action( $hook['hook'], [ $hook['component'], $hook['callback'] ], $hook['priority'], $hook['accepted_args'] );
}
}
}

Feature and module separation

Handler classes

Group related functionality into handler classes in inc/handler-classes/:

inc/handler-classes/
├── class-prefix-request-handler.php # HTTP request handling
├── class-prefix-post-handler.php # Post type operations
├── class-prefix-taxonomy-handler.php # Taxonomy operations
└── class-prefix-transient-handler.php # Caching/transients

Addons and integrations

Third-party integrations should use an abstract base class:

// inc/addons/class-prefix-addon-base.php
abstract class PREFIX_Addon_Base {
abstract public function init();
abstract public function is_active();

protected function register_hooks() {
// Common hook registration
}
}

// inc/addons/class-prefix-woocommerce.php
class PREFIX_WooCommerce extends PREFIX_Addon_Base {
public function is_active() {
return class_exists( 'WooCommerce' );
}

public function init() {
if ( ! $this->is_active() ) {
return;
}
$this->register_hooks();
}
}

Admin vs frontend separation

Admin class

The admin class handles all WordPress admin functionality:

class PREFIX_Admin {
private $plugin_name;
private $version;

public function __construct( $plugin_name, $version ) {
$this->plugin_name = $plugin_name;
$this->version = $version;
}

public function enqueue_styles() {
wp_enqueue_style(
$this->plugin_name,
plugin_dir_url( __FILE__ ) . 'css/prefix-admin.css',
[],
$this->version,
'all'
);
}

public function enqueue_scripts() {
wp_enqueue_script(
$this->plugin_name,
plugin_dir_url( __FILE__ ) . 'js/prefix-admin.js',
[ 'jquery' ],
$this->version,
true
);

wp_localize_script(
$this->plugin_name,
'prefixAdminObject',
[
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'prefix-admin-nonce' ),
]
);
}
}

Public class

The public class handles frontend functionality:

class PREFIX_Public {
private $plugin_name;
private $version;

public function __construct( $plugin_name, $version ) {
$this->plugin_name = $plugin_name;
$this->version = $version;
}

public function enqueue_styles() {
wp_enqueue_style(
$this->plugin_name,
plugin_dir_url( __FILE__ ) . 'css/prefix-public.css',
[],
$this->version,
'all'
);
}

public function enqueue_scripts() {
wp_enqueue_script(
$this->plugin_name,
plugin_dir_url( __FILE__ ) . 'js/prefix-public.js',
[ 'jquery' ],
$this->version,
true
);
}
}

Asset handling

Source file structure

assets/
├── js/
│ ├── admin.js # Admin JavaScript entry point
│ └── frontend.js # Frontend JavaScript entry point
└── scss/
├── admin-styles.scss # Admin SCSS entry point
├── frontend-styles.scss # Frontend SCSS entry point
└── _partials/ # SCSS partials
├── _variables.scss
├── _mixins.scss
└── _components.scss

Webpack configuration

Use @wordpress/scripts with custom webpack configuration:

// webpack.global.js
const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' );
const path = require( 'path' );

module.exports = {
...defaultConfig,
entry: {
admin: './assets/js/admin.js',
frontend: './assets/js/frontend.js',
},
output: {
path: path.resolve( __dirname, 'build' ),
filename: '[name].js',
},
module: {
rules: [
...defaultConfig.module.rules,
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
'css-loader',
'sass-loader',
],
},
],
},
plugins: [
...defaultConfig.plugins,
new MiniCssExtractPlugin( {
filename: '[name].css',
} ),
],
};

Build output

Built files are placed in appropriate directories:

  • Admin CSS → admin/css/prefix-admin.css
  • Admin JS → admin/js/prefix-admin.js
  • Public CSS → public/css/prefix-public.css
  • Public JS → public/js/prefix-public.js

Settings and options architecture

Single global option pattern

Store all plugin settings in a single option to reduce database queries:

// Option name
$option_name = 'prefix_global_settings';

// Get all settings
$settings = get_option( 'prefix_global_settings', [] );

// Get specific setting
$value = $settings['feature_enabled'] ?? false;

Settings registration

class PREFIX_Settings {
private $capability = 'manage_options';
private $option_group = 'prefix_settings_group';

public function __construct() {
add_action( 'admin_init', [ $this, 'register_settings' ] );
add_action( 'admin_menu', [ $this, 'add_settings_page' ] );
}

public function add_settings_page() {
add_options_page(
__( 'Plugin Settings', 'plugin-name' ),
__( 'Plugin Name', 'plugin-name' ),
$this->capability,
'prefix-settings',
[ $this, 'render_settings_page' ]
);
}

public function register_settings() {
register_setting(
$this->option_group,
'prefix_global_settings',
[
'type' => 'array',
'sanitize_callback' => [ $this, 'sanitize_settings' ],
'default' => [],
]
);

add_settings_section(
'prefix_general_section',
__( 'General Settings', 'plugin-name' ),
[ $this, 'render_section_description' ],
'prefix-settings'
);

add_settings_field(
'feature_enabled',
__( 'Enable Feature', 'plugin-name' ),
[ $this, 'render_checkbox_field' ],
'prefix-settings',
'prefix_general_section',
[ 'field' => 'feature_enabled' ]
);
}

public function sanitize_settings( $input ) {
$sanitized = [];

if ( isset( $input['feature_enabled'] ) ) {
$sanitized['feature_enabled'] = (bool) $input['feature_enabled'];
}

return $sanitized;
}
}

Tab-based settings UI

For complex settings, use a tabbed interface with individual option keys that consolidate into the global option:

add_filter( 'pre_update_option_prefix_tab_one_settings', [ $this, 'update_global_option' ], 10, 3 );
add_filter( 'pre_update_option_prefix_tab_two_settings', [ $this, 'update_global_option' ], 10, 3 );

public function update_global_option( $value, $old_value, $option ) {
$global = get_option( 'prefix_global_settings', [] );
$global[ $option ] = $value;
update_option( 'prefix_global_settings', $global );
return $old_value; // Return old value to prevent duplicate save
}

Build tools and configuration

Composer configuration

{
"name": "company/plugin-name",
"description": "Plugin description",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"require": {
"php": ">=8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
"phpcompatibility/phpcompatibility-wp": "^2.1",
"wp-coding-standards/wpcs": "^3.0"
},
"scripts": {
"lint": "phpcs",
"format": "phpcbf",
"pot": "wp i18n make-pot . languages/plugin-name.pot --exclude=node_modules,vendor,build"
},
"config": {
"allow-plugins": {
"composer/installers": true,
"dealerdirect/phpcodesniffer-composer-installer": true
}
}
}

npm configuration

{
"name": "plugin-name",
"version": "1.0.0",
"scripts": {
"build": "wp-scripts build --config webpack.global.js",
"start": "wp-scripts start --config webpack.global.js",
"lint:js": "wp-scripts lint-js",
"lint:css": "wp-scripts lint-style",
"lint:php": "composer run lint",
"format:js": "wp-scripts format",
"format:css": "wp-scripts lint-style --fix",
"format:php": "composer run format"
},
"devDependencies": {
"@wordpress/scripts": "^27.0.0",
"css-loader": "^6.0.0",
"mini-css-extract-plugin": "^2.0.0",
"sass": "^1.0.0",
"sass-loader": "^14.0.0"
}
}

PHPCS configuration

<?xml version="1.0"?>
<ruleset name="Plugin Coding Standards">
<description>PHP coding standards for the plugin.</description>

<file>.</file>

<exclude-pattern>/vendor/*</exclude-pattern>
<exclude-pattern>/node_modules/*</exclude-pattern>
<exclude-pattern>/build/*</exclude-pattern>

<arg name="extensions" value="php"/>
<arg name="colors"/>
<arg value="sp"/>

<config name="testVersion" value="8.0-"/>
<config name="minimum_wp_version" value="6.0"/>
<config name="text_domain" value="plugin-name"/>

<rule ref="WordPress-Extra"/>
<rule ref="WordPress-Docs"/>
<rule ref="PHPCompatibilityWP"/>

<!-- Allow short array syntax -->
<rule ref="Generic.Arrays.DisallowShortArraySyntax.Found">
<severity>0</severity>
</rule>
</ruleset>

EditorConfig

# .editorconfig
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = tab
indent_size = 4

[*.{json,yml,yaml}]
indent_style = space
indent_size = 2

[*.md]
trim_trailing_whitespace = false

Internationalization

Text domain

Use a single text domain matching the plugin slug:

// Correct
__( 'Hello World', 'plugin-name' );

// Incorrect - multiple domains
__( 'Hello World', 'my-plugin' );
__( 'Hello World', 'plugin_name' );

Loading translations

class PREFIX_i18n {
public function load_plugin_textdomain() {
load_plugin_textdomain(
'plugin-name',
false,
dirname( dirname( plugin_basename( __FILE__ ) ) ) . '/languages/'
);
}
}

Translation functions

FunctionUse case
__( $text, $domain )Return translated string
_e( $text, $domain )Echo translated string
esc_html__( $text, $domain )Return escaped translated string
esc_html_e( $text, $domain )Echo escaped translated string
esc_attr__( $text, $domain )Return escaped for attribute
esc_attr_e( $text, $domain )Echo escaped for attribute
_n( $single, $plural, $number, $domain )Pluralization
_x( $text, $context, $domain )Context-specific translation

POT file generation

composer run pot
# or
wp i18n make-pot . languages/plugin-name.pot --exclude=node_modules,vendor,build

Security practices

Nonces

Always use nonces for form submissions and AJAX requests:

// Creating a nonce
$nonce = wp_create_nonce( 'prefix-action-name' );

// Verifying in form handler
if ( ! wp_verify_nonce( $_POST['_wpnonce'], 'prefix-action-name' ) ) {
wp_die( __( 'Security check failed.', 'plugin-name' ) );
}

// Nonce field in form
wp_nonce_field( 'prefix-action-name', 'prefix_nonce' );

// AJAX nonce verification
check_ajax_referer( 'prefix-ajax-nonce', 'nonce' );

Capability checks

Always verify user capabilities:

// Settings pages
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( __( 'Unauthorized access.', 'plugin-name' ) );
}

// Post meta
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}

// Custom capability
if ( ! current_user_can( 'prefix_manage_feature' ) ) {
return;
}

Input sanitization

Always sanitize user input:

// Text fields
$text = sanitize_text_field( wp_unslash( $_POST['field'] ) );

// Keys/slugs
$key = sanitize_key( $_GET['action'] );

// Email
$email = sanitize_email( $_POST['email'] );

// URLs
$url = esc_url_raw( $_POST['url'] );

// HTML content
$content = wp_kses_post( $_POST['content'] );

// Arrays
$items = array_map( 'sanitize_text_field', wp_unslash( $_POST['items'] ) );

Output escaping

Always escape output:

// HTML content
echo esc_html( $text );

// Attributes
echo '<input value="' . esc_attr( $value ) . '">';

// URLs
echo '<a href="' . esc_url( $url ) . '">';

// JavaScript
echo '<script>var data = ' . wp_json_encode( $data ) . ';</script>';

// Allow specific HTML
echo wp_kses_post( $html_content );

Security checklist

  • Direct file access check (WPINC or ABSPATH)
  • Nonces for all forms and AJAX
  • Capability checks for admin actions
  • Input sanitization on all $_GET, $_POST, $_REQUEST
  • Output escaping for all dynamic content
  • SQL queries use $wpdb->prepare()
  • File uploads validated
  • Directory index protection (index.php)

Coding standards

PHP standards

Follow WordPress Coding Standards with these specifications:

  • PHP version: 8.0+ required
  • Indentation: Tabs (not spaces)
  • Line length: 100 characters soft limit
  • Braces: Opening brace on same line
  • Naming: snake_case for functions, UPPER_CASE for constants

File headers

<?php
/**
* Class description.
*
* @package Plugin_Name
* @subpackage Plugin_Name/directory
* @author Company Name <email@example.com>
* @since 1.0.0
*/

Function documentation

/**
* Short description of the function.
*
* Longer description if needed.
*
* @since 1.0.0
*
* @param string $param1 Description of param1.
* @param int $param2 Optional. Description of param2. Default 10.
*
* @return bool Description of return value.
*/
function prefix_function_name( $param1, $param2 = 10 ) {
// Function body
}

Hook documentation

/**
* Filters the feature output.
*
* @since 1.0.0
*
* @param string $output The output HTML.
* @param array $args The feature arguments.
*/
$output = apply_filters( 'prefix_filter_feature_output', $output, $args );

/**
* Fires after feature initialization.
*
* @since 1.0.0
*
* @param PREFIX_Feature $feature The feature instance.
*/
do_action( 'prefix_action_feature_init', $feature );

Quick reference checklist

Use this checklist when starting a new plugin:

Initial setup

  • Create directory structure per standard
  • Add security index.php files
  • Create main plugin file with proper headers
  • Set up composer.json with WPCS
  • Set up package.json with build tools
  • Configure PHPCS (phpcs.xml.dist)
  • Add .editorconfig

Core classes

  • Create main plugin class
  • Create hook loader class
  • Create i18n class
  • Create activator/deactivator classes
  • Create admin class
  • Create public class

Security

  • Add WPINC check to all PHP files
  • Implement nonce verification
  • Add capability checks
  • Sanitize all input
  • Escape all output

Assets

  • Set up SCSS source files
  • Set up JavaScript entry points
  • Configure webpack
  • Implement proper enqueueing

i18n

  • Set text domain in plugin header
  • Create languages directory
  • Use proper translation functions
  • Generate POT file

Documentation

  • Add inline code documentation
  • Create project README
  • Document hooks and filters

Owner: TBD | Last reviewed: TBD