Most WordPress sites need at least one piece of custom functionality: a booking rule, an integration with a CRM, an admin screen for a client's team. Putting that code in a small, well-built plugin rather than in the theme's functions.php means it survives theme changes, can be switched off for debugging and can be reused on the next project.
This guide is the structure and set of habits I use for custom plugins. None of it is exotic. It is what the WordPress coding standards and the Plugin Handbook recommend, applied consistently.
A structure that scalesLink to this section
acme-booking/
├── acme-booking.php main file: header, constants, bootstrap
├── uninstall.php removes options and tables on delete
├── includes/
│ ├── class-plugin.php wires hooks
│ ├── class-admin.php settings screen
│ └── class-rest.php REST endpoints
├── assets/ built JS/CSS
└── languages/The main file holds the plugin header and as little logic as possible:
<?php
/**
* Plugin Name: Acme Booking
* Description: Booking rules for Acme.
* Version: 1.0.0
* Requires PHP: 7.4
* Text Domain: acme-booking
*/
defined( 'ABSPATH' ) || exit;
define( 'ACME_BOOKING_VERSION', '1.0.0' );
define( 'ACME_BOOKING_DIR', plugin_dir_path( __FILE__ ) );
require_once ACME_BOOKING_DIR . 'includes/class-plugin.php';
add_action( 'plugins_loaded', array( 'Acme_Booking\Plugin', 'init' ) );
register_activation_hook( __FILE__, array( 'Acme_Booking\Plugin', 'activate' ) );Two habits prevent most conflicts with other plugins:
- Prefix or namespace everything: functions, classes, options, hooks you create, transients and CSS classes.
- Check
ABSPATHat the top of every PHP file so nothing runs if a file is requested directly.
Actions and filters, brieflyLink to this section
WordPress is built on hooks. An action runs your code at a moment (init, save_post, wp_enqueue_scripts). A filter receives a value, lets you change it and must return it.
// Action: do something when a booking post is saved.
add_action( 'save_post_acme_booking', 'acme_booking_notify_team', 10, 2 );
// Filter: change a value, and always return it.
add_filter( 'the_title', function ( $title, $post_id ) {
return get_post_type( $post_id ) === 'acme_booking' ? '#' . $post_id . ' ' . $title : $title;
}, 10, 2 );The third argument is the priority (lower runs earlier) and the fourth is how many arguments your callback accepts. Forgetting the fourth is a classic bug: your callback silently receives only the first argument.
The security rule: sanitize, validate, escapeLink to this section
Nearly every vulnerability report for WordPress plugins comes back to one of three missing steps.
| Step | When | Typical functions |
|---|---|---|
| Sanitize | Data comes in (forms, URLs, REST) | sanitize_text_field, sanitize_email, absint, esc_url_raw, sanitize_key |
| Validate | Before acting on it | is_email, in_array( $v, $allowed, true ), range checks |
| Escape | Data goes out to HTML | esc_html, esc_attr, esc_url, wp_kses_post |
Escape late, at the point of output, even for data you sanitized earlier. A value can change between save and display, and late escaping makes code reviews easy because every echo is visibly safe.
Nonces and capabilities on every formLink to this section
A nonce proves the request came from your form. A capability check proves the user is allowed to do the action. You need both: a nonce is not an authorization check.
// Render the form.
?>
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<?php wp_nonce_field( 'acme_save_rules', 'acme_nonce' ); ?>
<input type="hidden" name="action" value="acme_save_rules">
<label for="acme-limit"><?php esc_html_e( 'Daily limit', 'acme-booking' ); ?></label>
<input id="acme-limit" name="limit" type="number" min="1" max="500"
value="<?php echo esc_attr( get_option( 'acme_daily_limit', 20 ) ); ?>">
<?php submit_button(); ?>
</form>
<?php
// Handle it.
add_action( 'admin_post_acme_save_rules', function () {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'acme-booking' ), 403 );
}
check_admin_referer( 'acme_save_rules', 'acme_nonce' );
$limit = isset( $_POST['limit'] ) ? absint( $_POST['limit'] ) : 20;
$limit = min( max( $limit, 1 ), 500 ); // validate the range
update_option( 'acme_daily_limit', $limit, false );
wp_safe_redirect( add_query_arg( 'updated', '1', wp_get_referer() ) );
exit;
} );The false in update_option stops the option from being autoloaded on every request. Use it for anything that isn't needed on every page.
Safe database queriesLink to this section
If you need $wpdb, never put variables straight into SQL. Use prepare():
global $wpdb;
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT id, starts_at FROM {$wpdb->prefix}acme_slots WHERE day = %s AND capacity > %d",
$day,
0
)
);Before writing raw SQL, check whether WP_Query, get_posts or the metadata API already does what you need. Custom tables make sense for high-volume, structured data such as logs, bookings or analytics, not for a handful of settings.
REST endpoints: never skip permission_callbackLink to this section
Since WordPress 5.5, registering a route without permission_callback triggers a notice. That's a good thing. Decide explicitly who can call each route:
add_action( 'rest_api_init', function () {
register_rest_route( 'acme/v1', '/slots', array(
'methods' => WP_REST_Server::READABLE,
'callback' => 'acme_rest_get_slots',
'permission_callback' => '__return_true', // public, read-only
'args' => array(
'day' => array(
'required' => true,
'validate_callback' => function ( $v ) {
return (bool) preg_match( '/^\d{4}-\d{2}-\d{2}$/', $v );
},
),
),
) );
register_rest_route( 'acme/v1', '/slots', array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => 'acme_rest_create_slot',
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
) );
} );Validation patterns like the date check above are easy to get subtly wrong. Test them against real and malformed input in the regex tester before you ship. When you inspect what an endpoint returns, the JSON formatter makes nested responses readable.
Loading assets only where neededLink to this section
Enqueue scripts and styles only on the screens that use them. On the front end, check the context, and in the admin, use the hook suffix:
add_action( 'admin_enqueue_scripts', function ( $hook ) {
if ( 'settings_page_acme-booking' !== $hook ) {
return;
}
wp_enqueue_script(
'acme-admin',
plugins_url( 'assets/admin.js', __FILE__ ),
array(),
ACME_BOOKING_VERSION,
array( 'in_footer' => true, 'strategy' => 'defer' )
);
} );The strategy argument (WordPress 6.3+) adds defer or async properly, including for dependent scripts.
Activation, deactivation and uninstallLink to this section
- Activation: create custom tables with
dbDelta(), set default options, and callflush_rewrite_rules()once if you register post types or rewrite rules. - Deactivation: clear scheduled events with
wp_clear_scheduled_hook(). Don't delete data, because people deactivate plugins to debug. - Uninstall: in
uninstall.php, checkWP_UNINSTALL_PLUGINand remove your options and tables. Leaving clutter behind is how databases get slow.
<?php
// uninstall.php
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
delete_option( 'acme_daily_limit' );
global $wpdb;
$wpdb->query( "DROP TABLE IF EXISTS {$wpdb->prefix}acme_slots" );Scheduled tasks with WP-CronLink to this section
Booking reminders, cache clean-ups and API syncs usually run on a schedule. Register the event on activation and remove it on deactivation:
register_activation_hook( __FILE__, function () {
if ( ! wp_next_scheduled( 'acme_booking_send_reminders' ) ) {
wp_schedule_event( time(), 'hourly', 'acme_booking_send_reminders' );
}
} );
register_deactivation_hook( __FILE__, function () {
wp_clear_scheduled_hook( 'acme_booking_send_reminders' );
} );
add_action( 'acme_booking_send_reminders', 'acme_booking_send_due_reminders' );WP-Cron runs when someone visits the site, so on quiet sites tasks run late, and on busy sites the check adds a little work to page loads. For anything time-sensitive, disable the visit trigger and run it from the server's cron instead:
// wp-config.php
define( 'DISABLE_WP_CRON', true );# crontab: run due events every five minutes
*/5 * * * * wp cron event run --due-now --path=/var/www/html --quietwp cron event list shows what's scheduled and when it runs next, which is the first thing to check when "the reminders stopped".
TranslationsLink to this section
Wrap every user-facing string in a translation function with your text domain, and load the translations on init:
add_action( 'init', function () {
load_plugin_textdomain( 'acme-booking', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
} );Even if the client only needs English today, translatable strings make it simple to adjust wording later without touching code.
Tooling that catches mistakes earlyLink to this section
- PHP_CodeSniffer with WordPress Coding Standards flags missing escaping, nonces and prepared queries automatically.
- Query Monitor shows slow queries, hook timings and PHP notices while you develop.
WP_DEBUGandWP_DEBUG_LOGshould be on in every local and staging environment. The critical-error debugging guide shows how to read the log.- Use unique authentication keys and salts per environment. The wp-config salt generator creates a fresh set in your browser.
Checklist before handing a plugin to a clientLink to this section
- ☐ Every function, class, option and hook is prefixed or namespaced.
- ☐ Every input is sanitized and validated; every output is escaped.
- ☐ Every state-changing request checks a nonce and a capability.
- ☐ Every REST route has a deliberate
permission_callback. - ☐ SQL uses
$wpdb->prepare(). - ☐ Assets load only where used.
- ☐ Uninstall removes what the plugin created.
- ☐ PHPCS passes; no notices with
WP_DEBUGon.
Frequently asked questionsLink to this section
Should custom code go in functions.php or a plugin?Link to this section
In a plugin. functions.php belongs to the theme, so the code disappears with a redesign and can't be switched off on its own while debugging. Keep functions.php for presentation-only tweaks.
Classes or plain functions?Link to this section
Either works if names are prefixed. Small plugins are fine with a few prefixed functions. Once a plugin has admin screens, REST routes and background tasks, namespaced classes keep the pieces separate and easier to test.
How do I ship updates to a custom plugin on client sites?Link to this section
Version the plugin in Git and deploy it like the rest of the site, or run a private update server. Add an Update URI header (WordPress 5.8+) with your own URL so a plugin with the same folder name on WordPress.org can never overwrite yours through an automatic update.
When should I create a custom database table?Link to this section
When you store many rows of the same structured data, such as bookings, logs or form entries, and need to query them by several columns. For a few settings, options are simpler; for content people edit, custom post types are better.
Much client work, such as integrating listings on yacht brokerage or exhibition websites, comes down to small plugins like this one. If you need custom functionality built or an existing plugin audited, send me the details. For content structure, continue with designing custom post types editors understand.
Test yourself
3 quick questions on this lesson.
Finished this lesson?
Mark it complete to track your progress through the learning paths.