Writing a WordPress Plugin From Scratch
Build a WordPress plugin from scratch with hooks, activation and deactivation hooks, a settings page, sanitization, escaping, and secure options saving.
A WordPress plugin is, at its simplest, a folder inside wp-content/plugins holding one PHP file that opens with a comment header naming the plugin. Save that file and WordPress lists the plugin on the Plugins screen.
If you’ve been dropping snippets into a theme’s functions.php, you already know the catch: switch themes and the code goes with it. Moving that code into a plugin means the functionality survives a theme switch, which is the main reason site behavior belongs in plugins and presentation belongs in themes.
This article builds one small plugin end to end: a site notice printed in the footer, editable from a settings page that saves safely. Every code block is a valid state of the plugin, so you can stop after any section and activate what you have.
Key Takeaways
- A plugin is a folder in
wp-content/pluginswith one PHP file whose comment header names the plugin;Plugin Nameis the only required header field. - Plugin code does nothing until you attach a function to a hook with
add_action()oradd_filter(). - A filter callback must return the value it receives; return nothing and the filtered value goes blank.
- A safe settings page layers four things: a capability check, a nonce, sanitisation on input, and escaping on output.
register_activation_hook()is for defaults, deactivation is for temporary cleanup, and permanent data removal belongs to uninstall.
How to Create a WordPress Plugin File and Header
Create the folder wp-content/plugins/site-notice/ and inside it the file site-notice.php. WordPress builds the Plugins screen by reading the PHP files in the plugins folder and picking out the ones that open with a plugin header. Every file carrying a header counts as a separate plugin, so put the header in one file only.
<?php
/**
* Plugin Name: Site Notice
* Description: Shows a short notice in the site footer, editable from Settings.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 7.4
* Author: OpenReplay Team
* License: GPL-2.0-or-later
* Text Domain: site-notice
*/
The header requirements page lists every recognised field, including Update URI and Requires Plugins. The ones you’ll use most:
| Field | What WordPress does with it | Required |
|---|---|---|
| Plugin Name | Displays it in the Plugins list | Yes |
| Description | Shows it under the name | No |
| Version | Displays it; drives update comparisons | No |
| Requires at least / Requires PHP | Blocks activation on older environments | No |
| Author, License, Text Domain | Attribution, licensing, translation slug | No |
Save the file and the plugin appears on the Plugins screen. It activates cleanly and does nothing.
What Do Activation and Deactivation Hooks Do?
register_activation_hook() runs your callback once, at the moment someone switches the plugin on, which makes it a good place to write your starting option values into the database. register_deactivation_hook() is for throwing away whatever the plugin only needed while it was running, a cache being the usual example. Deleting things for good, options and custom tables included, is a job for uninstall instead, because people often switch a plugin off meaning to switch it back on later.
Add below the header:
function orp_activate() {
add_option( 'orp_notice_text', 'Welcome to the site.' );
}
register_activation_hook( __FILE__, 'orp_activate' );
function orp_deactivate() {
delete_transient( 'orp_notice_cache' );
}
register_deactivation_hook( __FILE__, 'orp_deactivate' );
The first argument, __FILE__, points at the main plugin file. Our plugin caches nothing yet, but the deactivation callback shows the shape: clean up temporary artifacts, leave the saved option alone. The orp_ prefix on every function and option name prevents collisions with core and other plugins.
Why Doesn’t My Plugin Code Run?
Code in a plugin file does not run on its own. WordPress only executes a function after you attach it to a hook, and until then the file is inert. add_action() takes a hook name and a callable, with an optional priority that defaults to 10.
function orp_print_notice() {
$notice = get_option( 'orp_notice_text' );
if ( $notice ) {
echo '<p class="orp-notice">' . esc_html( $notice ) . '</p>';
}
}
add_action( 'wp_footer', 'orp_print_notice' );
When the theme fires wp_footer, WordPress calls every function attached to it, including ours. Load any front-end page and the notice appears above the closing body tag.
Add a Filter: Change a Value and Return It
An action lets your function run at a point in WordPress’s lifecycle; a filter hands your function a value and expects the modified value back. A filter callback must return a value. If it returns nothing, PHP returns null, WordPress carries that null forward, and whatever you were filtering disappears from the site. Forget the return in a body_class callback and the body element loses its classes; forget it on the_content and every post renders empty.
function orp_body_class( $classes ) {
if ( get_option( 'orp_notice_text' ) ) {
$classes[] = 'orp-has-notice';
}
return $classes;
}
add_filter( 'body_class', 'orp_body_class' );
This receives the array of body classes, appends one when a notice is set, and returns the array so themes can style pages differently while the notice is live.
How Do You Build a WordPress Settings Page?
A settings page is three registrations: a menu page on the admin_menu hook, and a setting plus its section and field on the admin_init hook, as the Settings API chapter lays out. add_options_page() puts the page under Settings; register_setting() names the option and, critically, its sanitize_callback.
function orp_settings_menu() {
add_options_page( 'Site Notice', 'Site Notice', 'manage_options',
'orp-site-notice', 'orp_settings_page_html' );
}
add_action( 'admin_menu', 'orp_settings_menu' );
function orp_settings_init() {
register_setting( 'orp_settings', 'orp_notice_text', array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
) );
add_settings_section( 'orp_main', 'Notice', '__return_false', 'orp-site-notice' );
add_settings_field( 'orp_notice_text', 'Notice text',
'orp_notice_field_html', 'orp-site-notice', 'orp_main' );
}
add_action( 'admin_init', 'orp_settings_init' );
function orp_notice_field_html() {
$value = get_option( 'orp_notice_text', '' );
echo '<input type="text" name="orp_notice_text" value="'
. esc_attr( $value ) . '" class="regular-text">';
}
function orp_settings_page_html() {
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form action="options.php" method="post">
<?php
settings_fields( 'orp_settings' );
do_settings_sections( 'orp-site-notice' );
submit_button( 'Save Notice' );
?>
</form>
</div>
<?php
}
The form posts to options.php, and core handles the save. Note that register_setting() also accepts a default argument; we set ours in the activation hook instead, so pick one mechanism and stay consistent.
How Do You Handle the Submitted Value Safely?
A capability check answers whether this user is allowed to save settings; a nonce answers whether this user actually intended to submit this form. A settings page left on a client site needs both, and nonces must never substitute for the capability check.
Here, the Settings API covers most of it: settings_fields() prints the nonce and core checks it when the form comes back, and core blocks the save unless the current user holds manage_options, the capability wp-admin/options.php applies to settings pages by default. If you ever write a custom form handler instead, output the nonce with wp_nonce_field() and verify it with check_admin_referer(). Add the explicit guard to the page callback, matching the handbook’s own example:
function orp_settings_page_html() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
// ... form as above ...
}
Check a capability, never a role name like administrator. The remaining two layers are already in place: sanitise on the way into the database (sanitize_text_field as the sanitize_callback) and escape on the way out (esc_attr() in the field, esc_html() in the footer). The two operations are not interchangeable, and skipping either is how a stored option becomes an injection vector.
Activate It and Confirm It Works
Activate Site Notice on the Plugins screen, load the front end, and check for the notice in the footer. Change the text under Settings, Site Notice, and reload. If nothing happens, work through this list in order:
- Is the plugin actually activated, not just present?
- Does every string passed to
add_action()oradd_filter()exactly match a defined function name? - Is there whitespace or output before
<?php? WordPress reports “unexpected output” during activation when there is. - Turn on
WP_DEBUGinwp-config.php, withWP_DEBUG_LOGwriting errors towp-content/debug.log, and read the actual PHP error instead of guessing.
You now have a plugin with a default option, an action, a filter that returns its value, and a settings page saved through a nonce, a capability check, and a sanitiser. That is the baseline worth copying for every snippet still living in functions.php: move it over, prefix it, hook it, and keep the sanitise-in, escape-out pair intact.
FAQs
What is the difference between wp_verify_nonce and check_admin_referer?
check_admin_referer() verifies both the nonce and the referrer for forms and URLs on admin screens, and it halts the request with a 403 when verification fails. wp_verify_nonce() checks only the nonce and returns a result you handle yourself, which suits Ajax handlers and other custom contexts. Neither replaces a capability check: nonces confirm the user intended the action, current_user_can() confirms the user is allowed to perform it.
What happens to a plugin's saved options when it is deactivated?
Nothing: deactivation leaves options in the database, so settings are intact when the user re-enables the plugin. A deactivation callback should clear only short-lived things, such as transients or cached files. Permanent cleanup belongs to uninstall, implemented either with register_uninstall_hook() or an uninstall.php file in the plugin folder. If you use uninstall.php, it must check that the WP_UNINSTALL_PLUGIN constant is defined before deleting anything.
Can a WordPress plugin have more than one PHP file?
Yes, but only the main file should contain the plugin header comment. WordPress finds plugins by reading the PHP files in the plugins directory for that header, and each file that carries one shows up as its own plugin. Load additional files from the main file with require_once, and keep calls like register_activation_hook() pointing at the main plugin file, since their first parameter must reference that file.
Should a default option be set in register_setting or in the activation hook?
Use one mechanism, not both. register_setting() accepts a default argument that is returned when no value exists in the database, while add_option() in an activation hook writes an actual row once at activation. The activation approach guarantees the value exists on every request, including front-end requests; the register_setting() default only applies where the registration code has run. Mixing both creates two sources of truth for the same option.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k