WordPress

WordPress Custom Plugin Development — From Hooks to Plugin Check 2.0

By Domain India Team · DomainIndia SupportPublished 19 min read
Knowledge base article
Contents (28 sections)

Verdict at the top: If you're building a plugin for a single client site or your own use, and you understand the WordPress security model (nonces, capabilities, escaping, prepared statements), you can ship a working plugin in a weekend. If you're targeting the WordPress.org directory, plan for a review process that can take weeks: new submissions are screened automatically with the official Plugin Check tool before a human reviewer sees them, and most rejections come from the same handful of security and coding-standard issues. This guide covers the basics, the security model that gates everything, running Plugin Check yourself, and the mistakes we see go wrong on hosted sites.

Key takeaways

Plugins live in /wp-content/plugins/your-slug/. Hooks (actions + filters) are how you extend WordPress without touching core. Security is non-negotiable: nonces on every form, capability checks before privileged work, escape on output, prepare on SQL. Run Plugin Check (the official WordPress.org review tool) locally before submitting. WP-Cron isn't real cron — for anything time-sensitive, trigger it from a real cron job.

When to write a plugin instead of editing the theme

A common mistake: stuffing custom functionality into functions.php of your active theme. Three problems:

  • Theme switch → functionality vanishes.
  • Theme update → your changes overwritten.
  • Multisite networks can't share theme-local code.

Plugins solve all three. Write a plugin when the functionality:

  • Is not strictly visual (it belongs to behaviour, not presentation).
  • Should survive a theme change.
  • Might be reused across sites or sold.

Rule of thumb: if it defines a custom post type, adds an admin menu, integrates an external API, or changes how WordPress runs, it belongs in a plugin.

The minimum viable plugin

The smallest plugin is one PHP file at /wp-content/plugins/your-plugin-slug/your-plugin-slug.php:

php
<?php
/**
 * Plugin Name:       My Custom Plugin
 * Plugin URI:        https://yourdomain.com/my-custom-plugin
 * Description:       One-line description of what the plugin does.
 * Version:           1.0.0
 * Requires at least: 6.5
 * Requires PHP:      8.0
 * Author:            Your Name
 * Author URI:        https://yourdomain.com
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       my-custom-plugin
 * Domain Path:       /languages
 */

if (!defined('ABSPATH')) exit;

// Plugin code below...

Two things in that header that matter more than people think:

  • Requires at least — the oldest WordPress version you have actually tested. WordPress refuses to activate the plugin on older versions, so a wrong value either locks out users or lets it break on untested versions.
  • Requires PHP — the oldest PHP version your code really runs on. PHP 7.4, 8.0 and 8.1 are all end of life, but many sites still run them. If you require 8.2 or newer, sites on older PHP can't activate the plugin, so choose deliberately and test on the versions you claim.

The if (!defined('ABSPATH')) exit; line stops the file doing anything if someone requests it directly by URL. Without it, a direct request could run code outside WordPress or leak a path in an error message.

For anything beyond a trivial plugin, split into multiple files:

code
/wp-content/plugins/my-custom-plugin/
├── my-custom-plugin.php          # main file with header + bootstrap
├── includes/
│   ├── class-main.php            # core plugin class
│   ├── class-admin.php           # admin-side functionality
│   └── class-frontend.php        # frontend functionality
├── assets/
│   ├── css/
│   └── js/
├── languages/                    # translations
├── readme.txt                    # WordPress.org readme format
└── uninstall.php                 # cleanup on plugin deletion

Activation, deactivation, and uninstall — the lifecycle

Three lifecycle events. Get them right and your plugin behaves predictably; get them wrong and you leave database garbage on every install.

php
// Runs when someone activates the plugin
register_activation_hook(__FILE__, 'mcp_activate');
function mcp_activate() {
    global $wpdb;
    $charset_collate = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}mcp_logs (
        id         BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
        user_id    BIGINT(20) UNSIGNED NOT NULL,
        action     VARCHAR(100) NOT NULL,
        created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        KEY user_id (user_id)
    ) $charset_collate;";

    require_once ABSPATH . 'wp-admin/includes/upgrade.php';
    dbDelta($sql);
}

register_deactivation_hook(__FILE__, 'mcp_deactivate');
function mcp_deactivate() {
    flush_rewrite_rules();
}

Important distinction: deactivation is not uninstall. Users expect data to survive a deactivate. For real cleanup-on-delete, ship a separate uninstall.php:

php
<?php
// uninstall.php
if (!defined('WP_UNINSTALL_PLUGIN')) exit;

global $wpdb;
$wpdb->query("DROP TABLE IF EXISTS {$wpdb->prefix}mcp_logs");
delete_option('mcp_settings');

This file runs only when a user explicitly deletes the plugin from the Plugins screen — never on deactivate, never on update.

Actions vs filters — the core extension mechanism

WordPress exposes hundreds of hooks. Two kinds:

Actions — "something happened, do your thing." Return nothing; cause side effects.

php
add_action('init', 'mcp_register_post_type');
function mcp_register_post_type() {
    register_post_type('mcp_project', [
        'labels' => ['name' => 'Projects'],
        'public' => true,
        'show_in_rest' => true,    // makes it Gutenberg-compatible
    ]);
}

Common actions: init, wp_enqueue_scripts, admin_menu, save_post, wp_login.

Filters — "something is about to happen, modify this value first." Receive a value, return a modified value.

php
add_filter('the_content', 'mcp_append_signature');
function mcp_append_signature($content) {
    if (is_single()) {
        $content .= '<p><em>— Posted from My Plugin</em></p>';
    }
    return $content;
}

Common filters: the_content, the_title, wp_nav_menu_items, pre_get_posts.

Both add_action and add_filter accept optional 3rd and 4th parameters — priority (default 10) and number of arguments (default 1):

php
add_action('save_post', 'mcp_on_save', 20, 2);
function mcp_on_save($post_id, $post) {
    // runs after default priority-10 handlers, receives both args
}

Higher priority number = runs later. The most common bug here is forgetting to bump the arg-count when you need the second parameter — your callback silently gets null.

Admin menu + Settings API

The right way to register an admin menu:

php
add_action('admin_menu', 'mcp_admin_menu');
function mcp_admin_menu() {
    add_menu_page(
        'My Custom Plugin Settings',   // page title
        'My Plugin',                   // menu title
        'manage_options',              // required capability
        'mcp-settings',                // menu slug
        'mcp_settings_page',           // callback
        'dashicons-admin-plugins',     // icon
        65                             // position
    );
}

For the settings page itself, use the Settings API. Don't roll your own form-and-$_POST-handling — you will get the security wrong.

php
add_action('admin_init', 'mcp_register_settings');
function mcp_register_settings() {
    register_setting('mcp_options', 'mcp_options', [
        'sanitize_callback' => 'mcp_sanitize_options',
    ]);

    add_settings_section('mcp_main', 'Main Settings', null, 'mcp-settings');

    add_settings_field('mcp_api_key', 'API Key', 'mcp_api_key_callback', 'mcp-settings', 'mcp_main');
}

function mcp_api_key_callback() {
    $options = get_option('mcp_options', []);
    $value   = esc_attr($options['api_key'] ?? '');
    echo "<input type='text' name='mcp_options[api_key]' value='$value' size='50'>";
}

function mcp_sanitize_options($input) {
    $clean = [];
    $clean['api_key'] = sanitize_text_field($input['api_key'] ?? '');
    return $clean;
}

function mcp_settings_page() {
    if (!current_user_can('manage_options')) return;
    ?>
    <div class="wrap">
        <h1>My Plugin Settings</h1>
        <form method="post" action="options.php">
            <?php
            settings_fields('mcp_options');
            do_settings_sections('mcp-settings');
            submit_button();
            ?>
        </form>
    </div>
    <?php
}

The Settings API handles form rendering, nonce protection, saving, and your sanitisation callbacks automatically. This is one of those cases where the WordPress way is genuinely better than rolling your own.

Enqueueing CSS and JS — never inline

Direct script tags in template output bypass the dependency system, conflict-resolution, and version-busting that WordPress provides. Always use wp_enqueue_*:

php
add_action('wp_enqueue_scripts', 'mcp_frontend_assets');
function mcp_frontend_assets() {
    wp_enqueue_style(
        'mcp-frontend',
        plugins_url('assets/css/frontend.css', __FILE__),
        [],
        '1.0.0'
    );

    wp_enqueue_script(
        'mcp-frontend',
        plugins_url('assets/js/frontend.js', __FILE__),
        ['jquery'],
        '1.0.0',
        true                           // load in footer
    );

    wp_localize_script('mcp-frontend', 'mcpData', [
        'ajaxUrl' => admin_url('admin-ajax.php'),
        'nonce'   => wp_create_nonce('mcp_frontend'),
    ]);
}

For admin-only scripts use admin_enqueue_scripts. Bumping the version string busts the browser cache when you deploy updates — get this wrong and users keep seeing your old JS for weeks.

Security — the part where most plugins fail

WordPress.org pulls plugins from the directory regularly for security issues. Almost always one of these five:

1. Nonces on every form and AJAX action

Never trust $_POST without a nonce check:

php
// When rendering the form
wp_nonce_field('mcp_save_settings', 'mcp_nonce');

// When handling the submission
if (!isset($_POST['mcp_nonce']) || !wp_verify_nonce($_POST['mcp_nonce'], 'mcp_save_settings')) {
    wp_die('Security check failed');
}

Nonces prevent CSRF (cross-site request forgery) — the attack where a logged-in admin clicks a malicious link and unknowingly performs an action on their own site.

2. Capability checks

Before any privileged operation — even if the menu requires manage_options:

php
if (!current_user_can('manage_options')) {
    wp_die('Unauthorised');
}

Don't rely on admin-menu placement alone. Direct POST requests to admin URLs bypass menu visibility — you need the explicit cap check inside the handler. This is the single most common bug we see in customer plugins: the menu only appears for admins, but the form-handling endpoint accepts requests from any logged-in user.

3. Output escaping — every time

Every dynamic value in HTML output needs the right escape function:

ContextFunction
Inside HTML bodyesc_html($value)
Inside HTML attributeesc_attr($value)
In a URL / hrefesc_url($value)
Inside a textarea elementesc_textarea($value)
JSON data for JSwp_json_encode($value)
Allow limited HTML (user bios etc)wp_kses_post($value) or wp_kses($value, $allowed)

Never concatenate raw $_POST or database output into HTML without escaping. The "I'll fix it later" version of this lives forever.

4. SQL — $wpdb->prepare without exception

php
// DANGEROUS — SQL injection
$results = $wpdb->get_results("SELECT * FROM {$wpdb->prefix}mcp_logs WHERE user_id = $user_id");

// SAFE
$results = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}mcp_logs WHERE user_id = %d",
        $user_id
    )
);

Placeholders: %d for integer, %s for string, %f for float. There is no scenario where direct concatenation of user input into a SQL string is acceptable — Plugin Check flags unprepared queries.

5. Input sanitisation

Every raw input needs a sanitise function before use:

  • sanitize_text_field() — single-line text, strips tags
  • sanitize_email() — email addresses
  • sanitize_key() — keys / slugs (lowercase alphanumeric + underscore / hyphen)
  • sanitize_textarea_field() — multi-line text
  • absint() — absolute positive integer
  • esc_url_raw() — URLs before saving (different from esc_url() which is for output)

The pattern: sanitise on input, escape on output. Not the other way around.

Plugin Check — the gatekeeper

Plugin Check is the official WordPress.org tool that screens new plugin submissions automatically before a human reviewer sees them. It's open source and you can (and should) run it locally before submitting.

Install it on your local WordPress:

bash
wp plugin install plugin-check --activate

Then run it against your plugin:

bash
wp plugin check my-custom-plugin

What it and the reviewers commonly flag:

  • Direct $_POST/$_GET/$_REQUEST use without wp_unslash() and a sanitise function. This is the most common rejection.
  • Missing nonce verification on form/AJAX endpoints.
  • Missing capability checks on privileged actions.
  • Unescaped output in templates (any echo of dynamic content without esc_*).
  • Direct mysqli/PDO use instead of $wpdb.
  • Inclusion of disallowed code (eval, base64-encoded chunks, obfuscated JS, calls to external services without disclosure).
  • Versions out of sync between the plugin file header and readme.txt Stable tag.
  • Missing or invalid Text Domain (breaks i18n and triggers an automatic flag).
  • Generic prefixes (save_settings() will get rejected; you need a prefix like mcp_save_settings()).
  • Loading external assets (JS, fonts, CSS) from arbitrary CDNs without a clear privacy/disclosure justification.

Run Plugin Check against your plugin until it returns zero errors, and review every warning. A rejected submission goes back into the review queue; running the check locally takes seconds.

What goes wrong on real sites

The same plugin-related incident shapes recur on hosted WordPress sites. Typical examples:

Incident type 1 — spam through a public form. A homemade contact-form plugin sends an email for every submission, with no CAPTCHA, honeypot or rate limit, and lets the visitor control the recipient or headers. Bots find it within days and use it to send spam. On shared hosting the account soon hits its outgoing mail limit (200 messages per hour per account on cPanel), legitimate mail stops, and the server's reputation suffers. Note that nonces protect logged-in users from CSRF; they do not stop bots on a public form.

Incident type 2 — escalation via REST endpoint. A plugin registered a REST route with permission_callback => '__return_true' (a common copy-paste from old tutorials). Any unauthenticated user could hit the endpoint and create posts. Discovered when the customer noticed dozens of casino-spam posts published overnight.

Incident type 3 — plugin update breaks the site, no rollback. Customer auto-updates a popular plugin; new version conflicts with custom code in their theme. Site shows white screen. They kept no backup of their own. On cPanel and DirectAdmin hosting, the weekly JetBackup copies (five kept) can be restored from the control panel; on Webuzo, support restores from its nightly backup on request. Anything changed since the last backup is lost, so keep your own copies too.

Incident type 4 — privilege escalation via AJAX endpoint. A plugin's wp_ajax_* action lacked current_user_can(). Any logged-in subscriber could trigger the admin-only function and modify site settings.

The pattern: none of these needed a sophisticated zero-day. They were basic mistakes in permissions, input handling and testing, covered by the security section above. Automated tools such as Plugin Check catch some of them; a careful review of every endpoint catches the rest.

AJAX endpoints

WordPress ships admin-ajax.php for AJAX requests (works for both logged-in and not):

php
// Register endpoint
add_action('wp_ajax_mcp_save', 'mcp_ajax_save');          // logged-in users only
// For a guest-facing action you would also add 'wp_ajax_nopriv_mcp_save',
// but then the capability check below must not require a login.

function mcp_ajax_save() {
    check_ajax_referer('mcp_frontend', 'nonce');

    if (!current_user_can('edit_posts')) {
        wp_send_json_error('Unauthorised', 403);
    }

    $data = sanitize_text_field(wp_unslash($_POST['data'] ?? ''));
    // ... do work

    wp_send_json_success(['message' => 'Saved']);
}

Frontend JS calls:

javascript
fetch(mcpData.ajaxUrl, {
  method: 'POST',
  body: new URLSearchParams({
    action: 'mcp_save',
    nonce: mcpData.nonce,
    data: 'hello',
  }),
}).then(r => r.json()).then(console.log);

For modern plugins, prefer the REST API over admin-ajax — better typed, cacheable via HTTP caching, and native to Gutenberg's data layer.

REST API endpoint

php
add_action('rest_api_init', function () {
    register_rest_route('mcp/v1', '/logs/(?P<id>\d+)', [
        'methods'             => 'GET',
        'callback'            => 'mcp_rest_get_log',
        'permission_callback' => function () {
            return current_user_can('read');
        },
        'args' => [
            'id' => [
                'validate_callback' => fn($v) => is_numeric($v),
                'sanitize_callback' => 'absint',
            ],
        ],
    ]);
});

function mcp_rest_get_log(WP_REST_Request $request) {
    $id = $request->get_param('id');
    global $wpdb;
    $row = $wpdb->get_row($wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}mcp_logs WHERE id = %d",
        $id
    ));
    return $row ? rest_ensure_response($row) : new WP_Error('not_found', 'Log not found', ['status' => 404]);
}

Hit at https://yourdomain.com/wp-json/mcp/v1/logs/42.

Critical: always set permission_callback. WordPress raises a "doing it wrong" notice if it is missing. __return_true makes the route public: use it only for data that is genuinely public and read-only, never for routes that create or change anything.

Performance — the part most plugin tutorials skip

WordPress sites are slow because plugins are slow, and plugins are slow because most plugin authors never measure their work. A few patterns to avoid:

Don't run heavy code on every page load

The init action fires on every request. Putting expensive work there — DB queries, API calls, large array operations — slows every page on the site. Examples we see:

  • Plugin queries the entire wp_options table on init to "warm a cache" (hint: get_option already caches; you're doubling the work).
  • Plugin calls an external API on init to "check for updates" — adds 200-500 ms to every page on the site.
  • Plugin loops every published post on init to do something that should be a one-time scheduled job.

Move heavy work to: scheduled events (wp_schedule_event), admin-only actions (admin_init only), or genuinely lazy-loading patterns (run the work on the first request that needs it, then cache the result).

Use transients for cached values

WordPress's transient API gives you per-key TTL caching for free:

php
function mcp_get_external_data() {
    $cached = get_transient('mcp_external_data');
    if ($cached !== false) return $cached;

    $data = wp_remote_get('https://api.example.com/data');
    if (is_wp_error($data)) return null;
    $body = json_decode(wp_remote_retrieve_body($data), true);

    set_transient('mcp_external_data', $body, HOUR_IN_SECONDS);
    return $body;
}

The default storage is the options table. If the site has a persistent object cache (a Redis or Memcached drop-in), transients are stored there instead. Redis and Memcached are not available on Domain India shared hosting, so transients use the options table there, which is fine for most sites; clean up expired transients you create.

Don't load assets on every page

The wp_enqueue_scripts action fires for every frontend page. If your plugin only matters on a specific shortcode or post type, gate the enqueue:

php
add_action('wp_enqueue_scripts', function () {
    if (!is_singular('mcp_project')) return;
    wp_enqueue_script('mcp-frontend', ...);
    wp_enqueue_style('mcp-frontend', ...);
});

Otherwise every page on the site downloads your CSS and JS, and PageSpeed scores tank.

WP-Cron isn't real cron

wp_schedule_event is "WP-Cron" — a pseudo-cron that runs on page-load. If your site has no traffic, your scheduled events don't fire. If your site has heavy traffic, they fire excessively (because every visitor triggers the cron check).

For anything time-sensitive (payment retries, inventory checks, imports), set define('DISABLE_WP_CRON', true); in wp-config.php and trigger it from a real cron job:

cron
*/5 * * * * cd /home/USERNAME/public_html && php wp-cron.php > /dev/null 2>&1

On Domain India shared hosting (cPanel and DirectAdmin), cron jobs run at most every 4 minutes: a job set to every 1, 2 or 3 minutes is changed to every 4 minutes. On your own VPS you can run it every minute.

Block editor (Gutenberg) integration

For plugins that add to the editor experience, register blocks via block.json (modern) rather than the older PHP-side register_block_type:

json
{
  "$schema": "https://schemas.wp.org/trunk/block.json",
  "apiVersion": 3,
  "name": "mcp/featured-project",
  "title": "Featured Project",
  "category": "widgets",
  "icon": "star",
  "description": "Display a featured project from MCP.",
  "keywords": ["project", "featured"],
  "version": "1.0.0",
  "textdomain": "my-custom-plugin",
  "editorScript": "file:./build/index.js",
  "editorStyle": "file:./build/index.css",
  "style": "file:./build/style-index.css",
  "attributes": {
    "projectId": { "type": "number" }
  }
}

Then load with one PHP call:

php
add_action('init', function () {
    register_block_type(__DIR__ . '/blocks/featured-project');
});

For data-flow between editor and server, register your post-type or option with show_in_rest => true and use the @wordpress/data package on the JS side. The useEntityProp hook is your friend.

Packaging for distribution

For WordPress.org directory submission:

  • readme.txt in the WordPress-specific format (see the WP plugin handbook readme spec).
  • Version number identical between header comment and readme's "Stable tag".
  • Banner images in the SVN repository's top-level assets/ folder (not inside your plugin zip): banner-1544x500.png and banner-772x250.png.
  • Icon in the same assets/ folder: icon-256x256.png (or .svg).
  • "Tested up to" in readme — keep current with the latest WordPress major version.
  • Run Plugin Check with zero errors before submitting.

For self-hosted distribution:

bash
cd /wp-content/plugins/
zip -r my-custom-plugin-1.0.0.zip my-custom-plugin \
    -x "my-custom-plugin/.git/*" \
    -x "my-custom-plugin/node_modules/*" \
    -x "my-custom-plugin/tests/*" \
    -x "my-custom-plugin/.github/*"

For premium distribution with auto-updates: Easy Digital Downloads (with its Software Licensing add-on), Freemius, or build your own with the pre_set_site_transient_update_plugins filter.

Selling plugins — practical reality for Indian developers

Many developers build plugins to sell. A few practical notes (marketplace terms and tax rules change, so check the current ones):

  • CodeCanyon (Envato) has a large WordPress-plugin marketplace audience but takes a substantial commission (check Envato's current rates) and sets its own licensing terms. Strong for a first revenue stream; weak for control.
  • Direct sales via your own site with EDD or Freemius gives you 100% revenue minus payment-gateway fees. You own the customer relationship and can upsell.
  • WordPress.org free + paid Pro version is the highest-trust path for serious products. The free version on WordPress.org drives discovery; the Pro version sold from your site captures revenue.
  • GST treatment — digital sales to Indian customers generally attract GST once you are registered. Sales to foreign customers may count as an export of services (zero-rated) only if the conditions are met, including payment received in convertible foreign exchange. How your payment gateway settles foreign payments affects this, so confirm the treatment with your CA. (General information, not tax advice.)
  • GPL licensing — WordPress is GPL, so distributed plugins must be GPL-compatible. You can sell GPL code; you cannot prevent buyers from redistributing it. The standard model is: sell the plugin + sell the support/updates contract.

Common errors and what they mean

Plugin file does not exist — your plugin folder name doesn't match the main file name pattern, or the main file is missing the header comment.

The plugin generated XYZ characters of unexpected output during activation — your activation hook is echoing something. Check for stray var_dump() or print_r() calls.

Cannot redeclare function ... — two plugins (or two copies of yours) defining the same function name. Always prefix.

PHP Fatal error: Uncaught Error: Class "WP_REST_Request" not found — you're trying to use REST classes outside the REST request context, or your plugin loaded too early. Wrap in add_action('rest_api_init', ...).

Notice: Function _load_textdomain_just_in_time was called incorrectly — common in WordPress 6.7+. You're calling translation functions before init. Move them inside an init callback.

Sorry, you are not allowed to access this page — capability check fired. The user lacks the cap you specified, (If you forgot the capability check entirely, the page would load for everyone, which is worse.)

The site is experiencing technical difficulties — fatal PHP error, WP's recovery mode kicked in. Check wp-content/debug.log (set WP_DEBUG_LOG to true in wp-config.php).

Testing locally before uploading

Use LocalWP, Lando, or a simple Docker setup:

yaml
# docker-compose.yml
services:
  wp:
    image: wordpress:php8.3
    ports: ["8080:80"]
    volumes:
      - ./my-plugin:/var/www/html/wp-content/plugins/my-plugin
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: root
      WORDPRESS_DB_PASSWORD: password
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DEBUG: 1
  db:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: wordpress

docker compose up, visit localhost:8080, complete the WordPress setup and activate your plugin. Install Plugin Check from Plugins → Add New (the wordpress image has no WP-CLI; use the wordpress:cli image if you want wp commands). This Docker setup is for your own computer: Docker does not run on shared hosting (see our Docker basics guide).

Frequently asked questions

How do I update my plugin after distribution?

WordPress.org-hosted: bump the version in the header AND readme.txt Stable tag, push a new SVN tag. Self-hosted: bump version, distribute new zip, ask users to reinstall (or implement EDD/Freemius update server).

Can I charge for a plugin?

Yes. WordPress's GPL licence means you can't restrict redistribution of the code, but you can absolutely sell the plugin and its support/updates contract. EDD and Freemius handle licensing.

Should I use Composer in my plugin?

You can, but run Composer on your own computer or in CI and ship the vendor folder with the plugin. Composer cannot run on Domain India shared hosting. For plugins distributed to others, Composer adds complexity: dependencies can collide with other plugins' copies, so prefix namespaces (for example with a tool such as PHP-Scoper) and use your own namespace, such as MyCompany\MyPlugin.

How do I run background work from a plugin?

Use WP-Cron (wp_schedule_event) for non-critical work and trigger wp-cron.php from a real cron job for anything time-sensitive. Shared hosting stops long-running queue workers and runs cron at most every 4 minutes, so work that needs a permanent worker process belongs on a VPS. WP-Cron's "fires on page load" behaviour is the gotcha most plugins get wrong.

Will my plugin work on WordPress Multisite?

Usually yes with care. Use $wpdb->prefix (site-specific) for data that should be per-site; $wpdb->base_prefix for network-wide. Use switch_to_blog() when querying across sites. Test with multisite enabled before claiming compatibility.

Can I use modern PHP features in a plugin?

Yes, if you set Requires PHP in the header to match. Sites on an older PHP version then can't activate the plugin. Enums and readonly properties need PHP 8.1; match expressions need 8.0. WordPress core still supports older PHP versions than many plugins require, so check the minimum on wordpress.org and decide which sites you are willing to exclude.

Do I need to support both Classic and Block editors?

Practically yes: many sites still use the Classic Editor plugin. For new plugins, build for the block editor first, but at minimum don't break Classic. Test in both.

How long does WordPress.org plugin review take in 2026?

It varies with the size of the review queue, and WordPress.org publishes the current queue status on its plugin review pages. Each round of fixes adds time. Passing Plugin Check before you submit avoids the most common round trips.

What gets a plugin permanently removed from WordPress.org?

Critical security issues (SQL injection, RCE, privilege escalation), persistent licence violations, malware/obfuscated code, calling external services without disclosure, or plagiarism. Removal is rare but final.

Bottom line

Building a working WordPress plugin is straightforward — hooks, settings API, escape on output, prepare on SQL. Building a plugin that survives WordPress.org review and doesn't show up in our security-incident logs takes one extra step: install Plugin Check, run it locally, and fix every error and warning before submission. That step is the difference between a plugin that lives on the directory and one that gets pulled.

If you're hosting your custom plugin on Domain India shared hosting, PHP and MySQL work as normal. Send email with wp_mail(), which uses PHP mail(): SMTP connections from PHP don't work on shared hosting (see sending mail with PHPMailer). Some PHP functions are disabled, so check the disabled functions list if your plugin runs shell commands or opens sockets. The moment your plugin needs permanent queue workers, Redis or WebSocket connections, you've crossed into VPS territory.

Questions about how your plugin behaves on our hosting? Open a support ticket or use live chat. Support can help with hosting-side issues; we don't write or debug custom plugin code.

Plugin outgrown shared hosting?

A self-managed Domain India VPS gives you full root access to run Redis, queue workers and your own PHP stack, from ₹553 a month excluding GST.

See VPS plans

Was this article helpful?

Your answer helps us decide what to improve next.

Still need help? Open a support ticket and our team will reply.

Prefer an app? Add this site to your home screen.Get the app