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.
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
/**
* 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:
/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 deletionActivation, 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.
// 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
// 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.
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.
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):
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:
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.
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_*:
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:
// 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:
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:
| Context | Function |
|---|---|
| Inside HTML body | esc_html($value) |
| Inside HTML attribute | esc_attr($value) |
| In a URL / href | esc_url($value) |
| Inside a textarea element | esc_textarea($value) |
| JSON data for JS | wp_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
// 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 tagssanitize_email()— email addressessanitize_key()— keys / slugs (lowercase alphanumeric + underscore / hyphen)sanitize_textarea_field()— multi-line textabsint()— absolute positive integeresc_url_raw()— URLs before saving (different fromesc_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:
wp plugin install plugin-check --activateThen run it against your plugin:
wp plugin check my-custom-pluginWhat it and the reviewers commonly flag:
- Direct
$_POST/$_GET/$_REQUESTuse withoutwp_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/PDOuse 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.txtStable tag. - Missing or invalid
Text Domain(breaks i18n and triggers an automatic flag). - Generic prefixes (
save_settings()will get rejected; you need a prefix likemcp_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):
// 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:
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
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_optionstable oninitto "warm a cache" (hint:get_optionalready caches; you're doubling the work). - Plugin calls an external API on
initto "check for updates" — adds 200-500 ms to every page on the site. - Plugin loops every published post on
initto 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:
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:
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:
*/5 * * * * cd /home/USERNAME/public_html && php wp-cron.php > /dev/null 2>&1On 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:
{
"$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:
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.txtin 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.pngandbanner-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:
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:
# 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: wordpressdocker 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.
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