WordPress Custom Plugin Development — From Hooks to Plugin Check 2.0
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 WordPress.org distribution in 2026, plan for a multi-week review process: WordPress.org now uses Plugin Check 2.0 for automated screening, and roughly 40% of first-time plugin submissions get rejected on automated checks alone — most often for the same five issues. This guide covers the basics, the security model that gates everything, the Plugin Check 2.0 reality, and the patterns we see go wrong on customer sites.
/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. Plugin Check 2.0 (the WordPress.org review tool) now blocks 5 specific issues automatically; install it locally before submitting. WP-Cron isn't real cron — for anything time-sensitive, queue work externally.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` — set to a version you've actually tested against. WordPress.org's review will pull random versions in that range; lying breaks the review.
- `Requires PHP` — keep it as low as your code allows but no lower. PHP 7.4 is past EOL; PHP 8.0+ is reasonable for new plugins in 2026.
The if (!defined('ABSPATH')) exit; line stops someone from hitting your plugin file directly via URL. Without it, a misconfigured server might leak a path or stack trace.
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> | 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 2.0 will flag and reject this.
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 2.0 — the new gatekeeper
In 2026 WordPress.org migrated all plugin reviews to Plugin Check 2.0 — an official, automated linter that every submission goes through before a human reviewer sees it. It's open-source and you can (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 catches that gets plugins rejected:
- 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.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 2.0 against your plugin until it returns zero errors and zero warnings. A WordPress.org first-submission rejection currently costs ~7-10 days of review-queue time; running the check locally costs 30 seconds.
What we see go wrong on customer sites
We host enough WordPress sites to see the same plugin-related incident shapes recur. Anonymised:
Incident type 1 — comment-spam tsunami. A plugin (often a homemade contact-form or comment enhancement) shipped without nonce verification on its public submission endpoint. Bots discovered it within days, and the site sent 50,000 spam emails through the cPanel mail server before our outgoing-mail-hold flagged the account. Recovery: delete the plugin, rotate mail passwords, release the hold. Months of work to rebuild the IP reputation.
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 don't have backups (relied on auto-updates for "safety"). We restore from JetBackup.
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: every one of these is one of the five security issues above. None of them needed a sophisticated zero-day; they were security mistakes that Plugin Check 2.0 would have caught on submission.
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
add_action('wp_ajax_nopriv_mcp_save', 'mcp_ajax_save'); // guests
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: the permission_callback is mandatory. Plugin Check 2.0 hard-rejects any REST route without it (or with __return_true, which is functionally identical to no check).
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;
}Default backend is the options table; if Redis or Memcached is available, transients use those automatically. On Domain India shared hosting, transients fall back to the options table — fine for low-traffic sites.
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, scraper runs), set define('DISABLE_WP_CRON', true); in wp-config.php and trigger via real cron:
* * * * * curl -s https://yourdomain.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1We have a separate KB article on the WP-Cron issue; check it before relying on WP-Cron for anything important.
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
/assets/:banner-1544x500.pngandbanner-772x250.png. - Icon in
/assets/:icon-256x256.png(or.svg). - "Tested up to" in readme — keep current with latest WP major.
- Run Plugin Check 2.0 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, Freemius, or build your own with pre_set_site_transient_update_plugins filter.
Selling plugins — practical reality for Indian developers
Many of our customers build plugins to sell. A few practical notes:
- CodeCanyon (Envato) has the largest WP-plugin marketplace audience but takes ~37.5% commission and locks you into their 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 — plugin sales to Indian customers attract 18% GST under SaaS/digital-service rules. Sales to foreign customers can be export-of-service (zero-rated) only with proper FIRC documentation. Razorpay/Stripe-INR/UPI receipts don't qualify as export earnings; you need an actual foreign currency wire.
- 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 versions 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, or you forgot the cap check entirely (which would have shown a 200 instead — Plugin Check 2.0 catches this).
`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_PASSWORD: password
WORDPRESS_DEBUG: 1
db:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: wordpressdocker compose up, visit localhost:8080, complete WP setup, activate plugin. Add wp plugin install plugin-check --activate once installed. (Note: this Docker pattern is for your laptop — not for shared hosting; see our Docker on cPanel article.)
Frequently asked questions
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).
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.
For plugins distributed to others — Composer adds complexity (you bundle vendor/ in your zip, watch for naming collisions). Use prefixed namespaces (MyCompany\MyPlugin\). For custom site plugins on a single hosting account, Composer is fine.
WP-Cron (wp_schedule_event) for non-critical work; defer to external queues for anything time-sensitive. On shared hosting, your queue-worker options are limited — see our background-jobs guide. WP-Cron's "fires on page-load" behaviour is the gotcha most plugins get wrong.
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.
Set Requires PHP: in the header. If you need PHP 8.0+, only users on that version can activate. You can use typed properties, enums, readonly, match expressions, etc. WordPress core itself runs on PHP 7.4+ so don't drop below that without a strong reason.
plugins_url() and plugin_dir_url()?
plugins_url($path, __FILE__) returns a URL to an asset inside your plugin. plugin_dir_url(__FILE__) returns the base URL of your plugin directory. Both follow the site's HTTPS/HTTP setting.
Practically yes — millions of sites still use Classic. For new plugins, build for Block editor first, but at minimum don't break Classic. Test in both.
First submission: 5-14 days, longer if there are review iterations. Plugin Check 2.0 catches obvious issues before a human reviewer sees it; passing automated checks shortens the human review noticeably.
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 2.0, run it locally, fix every 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, the PHP+MySQL+SMTP basics work out of the box. The moment your plugin needs real-time queue workers, Redis-backed transients, or websocket connections to the WordPress site, you've crossed into VPS territory — see our Laravel and Docker on cPanel guides for the same threshold mapped to PHP frameworks.
Questions on a specific plugin you're building? [email protected] — we troubleshoot WordPress hosting issues as part of standard support, though we don't write plugins on request.
Need a faster WordPress host or a VPS for a plugin that has outgrown shared? Domain India VPS Starter ₹553/month — root access, Redis-ready, sized for production WordPress. Get a VPS plan