The cardinal rule of WordPress development is that you never modify core, because every update overwrites it. Everything you add goes in a plugin, and the smallest valid plugin is one PHP file with a comment block at the top. The distance between that and something maintainable is mostly structure and three security habits.
The official handbook covers the API comprehensively. What it does not do is tell you which parts matter on day one, or which mistakes will bite six months later. This is the working subset.
Table of contents
- The minimum viable plugin
- Hooks, which are the whole programming model
- Loading assets correctly
- The three security habits
- Activation, deactivation, and uninstall
- Development practices worth adopting immediately
- How this fits the rest of the stack
- FAQ
The minimum viable plugin
A plugin is a PHP file in wp-content/plugins with a header comment. WordPress scans for that comment and lists what it finds.
<?php
/**
* Plugin Name: Order Notifier
* Description: Sends a notification when an order changes status.
* Version: 1.0.0
* Author: Your Name
* License: GPL-2.0-or-later
* Text Domain: order-notifier
*/
// Refuse to run if loaded directly rather than through WordPress.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
That ABSPATH guard belongs at the top of every PHP file in the plugin. Without it, anyone who requests the file directly executes it outside WordPress, with none of the environment your code assumes. It is two lines and it closes a real hole.
For anything beyond a single function, use a directory rather than a loose file, with the main file matching the directory name. That is the structure the plugin repository expects and the one every other developer will look for.
order-notifier/
order-notifier.php main file, header and bootstrap only
uninstall.php runs on delete, not deactivate
includes/ core classes
admin/ admin-only code
public/ front-end code
assets/ css, js, images
languages/ translation files
Hooks, which are the whole programming model
WordPress calls your code through hooks. Actions fire at a point in execution so you can do something; filters pass a value through so you can change it and return it.
// Action: do something at a point in time. Return value ignored.
add_action( 'init', 'on_init' );
function on_init() {
register_post_type( 'review', array( /* ... */ ) );
}
// Filter: receive a value, return a modified one. You MUST return.
add_filter( 'the_content', 'append_disclaimer' );
function append_disclaimer( $content ) {
if ( is_single() && 'review' === get_post_type() ) {
$content .= '<p class="disclaimer">Reviewed independently.</p>';
}
return $content; // forgetting this blanks every post
}
That comment is the single most common beginner bug. A filter callback that does not return leaves the value null, and the symptom is content disappearing site-wide with no error.
The priority and argument-count parameters matter more than they look.
// Priority 20 runs after the default 10. Higher runs later.
add_filter( 'the_title', 'my_title_filter', 20, 2 );
function my_title_filter( $title, $post_id ) {
// Without the 4th argument set to 2, $post_id is never passed.
return $title;
}
The hooks worth knowing early: init for registering things, wp_enqueue_scripts for front-end assets, admin_enqueue_scripts for admin assets, admin_menu for settings pages, and save_post for reacting to content changes.
Loading assets correctly
Never write a script or link tag into the page. WordPress has a dependency system, and bypassing it causes duplicate jQuery, load-order bugs, and conflicts with other plugins.
add_action( 'wp_enqueue_scripts', 'on_enqueue_assets' );
function on_enqueue_assets() {
$plugin_url = plugin_dir_url( __FILE__ );
$version = '1.0.0';
wp_enqueue_style( 'order-notifier', $plugin_url . 'assets/style.css', array(), $version );
wp_enqueue_script(
'order-notifier',
$plugin_url . 'assets/app.js',
array( 'jquery' ), // dependencies, loaded first
$version,
true // in the footer
);
// Pass data to JavaScript. Never echo PHP into a script tag.
wp_localize_script( 'order-notifier', 'orderNotifier', array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'order_notifier' ),
) );
}
Use the version parameter and bump it on every release. It is appended as a query string, and skipping it means users keep a cached copy of the old file after you ship a fix.
Load assets only where they are needed. A plugin that enqueues its stylesheet on every page of the admin is a plugin other developers will complain about, and it is a common cause of admin slowdowns on sites running many plugins.
The three security habits
Nearly every WordPress vulnerability comes down to one of these three being skipped. They are not optional and they are not difficult.
First, escape on output. Every value printed to a page gets escaped according to context.
echo esc_html( $title ); // text content
echo esc_attr( $value ); // inside an attribute
echo esc_url( $link ); // href or src
echo wp_kses_post( $rich_text ); // limited HTML allowed
// Wrong: prints whatever was stored, including a script tag.
echo $title;
Second, sanitise on input, and verify the request was intentional. A nonce proves the request came from your form rather than from a page on another site, and a capability check proves the user is allowed to do it.
add_action( 'admin_post_save_settings', 'handle_save' );
function handle_save() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Insufficient permissions.' );
}
check_admin_referer( 'save_settings' );
$email = sanitize_email( wp_unslash( $_POST['notify_email'] ?? '' ) );
$count = absint( $_POST['count'] ?? 0 );
update_option( 'order_notifier_email', $email );
wp_safe_redirect( admin_url( 'options-general.php?page=order-notifier' ) );
exit;
}
Both checks are needed. A nonce without a capability check stops cross-site requests but lets any logged-in subscriber change your settings. A capability check without a nonce lets an attacker trick an administrator into making the change.
Third, prepare every query. Never interpolate a variable into SQL.
global $wpdb;
$results = $wpdb->get_results( $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}orders WHERE status = %s AND total > %d",
$status,
$minimum
) );
Use the prefix property rather than a hardcoded table name, since installations can use a custom prefix and multisite uses per-site prefixes.
Activation, deactivation, and uninstall
Three distinct lifecycle events that people routinely conflate, with real consequences.
register_activation_hook( __FILE__, 'on_activate' );
function on_activate() {
add_option( 'order_notifier_version', '1.0.0' );
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
global $wpdb;
dbDelta( "CREATE TABLE {$wpdb->prefix}order_log (
id bigint(20) NOT NULL AUTO_INCREMENT,
order_id bigint(20) NOT NULL,
PRIMARY KEY (id)
) {$wpdb->get_charset_collate()};" );
flush_rewrite_rules(); // only if you registered post types or rules
}
register_deactivation_hook( __FILE__, 'on_deactivate' );
function on_deactivate() {
wp_clear_scheduled_hook( 'order_notifier_daily' );
flush_rewrite_rules();
// Do NOT delete user data here.
}
The rule that matters: deactivation is temporary and must not destroy anything. Users deactivate plugins to test a conflict, and a plugin that deletes their configuration when they do is a plugin they will not reactivate.
Permanent cleanup belongs in uninstall.php, which runs only when the plugin is deleted.
<?php
// uninstall.php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}
delete_option( 'order_notifier_email' );
delete_option( 'order_notifier_version' );
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}order_log" );
Scheduled events are the classic leak. A plugin that registers a cron event on activation and does not clear it on deactivation leaves WordPress trying to fire a hook that no longer exists, forever.
Development practices worth adopting immediately
- Turn on WP_DEBUG, WP_DEBUG_LOG, and WP_DEBUG_DISPLAY in wp-config.php on your development site. Most plugin bugs are notices you cannot see with them off.
- Prefix every function, class, and option name, or use a namespace. Two plugins defining a function called get_settings is a fatal error, and it will be blamed on you.
- Never edit files through the admin theme editor. Use version control and deploy properly.
- Test with a different theme and with other plugins active. A plugin that only works on your setup is not finished.
- Use a child theme for theme changes, and never put plugin functionality in functions.php, or it disappears when the theme changes.
The prefixing point is worth dwelling on because the failure mode is severe. WordPress has no module system, so everything shares one global namespace. A collision is a white screen on a site running your plugin plus someone else’s, and diagnosing it falls to whoever is unluckiest.
How this fits the rest of the stack
Plugin development is faster when editing a file and checking the result does not involve an SFTP client, and when looking at what a query actually wrote does not mean installing a database tool. Managed WordPress on RunxBuild includes a file manager and a database browser in the dashboard, which covers both. The RunxBuild hosting calculator shows what a WordPress plan costs alongside anything else the project runs, with plans starting at $3 a month.
Useful related references:
- How to Duplicate a Page in WordPress, With and Without a Plugin
- CSS Animations in WordPress Without a Plugin or a Performance Hit
- A WordPress Plugin Stopped Working: The Debug Order That Finds It
- Services on RunxBuild
FAQ
What is the minimum a WordPress plugin needs?
One PHP file in wp-content/plugins with a header comment containing at least a Plugin Name. For anything beyond a single function, use a directory whose name matches the main file.
What is the difference between an action and a filter?
An action fires at a point in execution so you can do something, and its return value is ignored. A filter passes a value through for you to modify and return. A filter callback that does not return blanks the value.
Why did my content disappear after adding a filter?
The filter callback did not return a value. WordPress uses whatever comes back, so a missing return sets the content to null across the site. Every filter must return.
Should a plugin delete its data on deactivation?
No. Users deactivate to test conflicts, so deactivation must be reversible. Put permanent cleanup in uninstall.php, which runs only when the plugin is deleted, and do clear scheduled events on deactivation.
How do I stop my plugin conflicting with others?
Prefix every function, class, constant, and option name, or use a PHP namespace. WordPress has one global namespace, so two plugins defining the same function name is a fatal error.