Creating a WordPress plugin takes one folder, one PHP file, and a comment block at the top of that file. WordPress reads the comment block, lists your plugin on the Plugins screen, and once you activate it your code runs on every request. Everything after that is a question of choosing the right hook and writing code that cannot be abused.
wp-content/plugins/, add a PHP file with the same name, paste a plugin header comment into it containing at least a Plugin Name: line, then activate it under Plugins in the admin. Add behavior with add_action() and add_filter(), block direct file access with a defined( 'ABSPATH' ) check, and verify every form submission with a nonce before you save anything.This tutorial builds a tiny but complete plugin called GB Notice Bar. It stores one setting, prints a message in the site footer, cleans up after itself, and does all of that with the security checks that reviewers and security scanners expect. The current release line is WordPress 7.1, and everything below works on WordPress 6.5 and later. If you do not have a test site yet, set one up locally first using our guide to installing WordPress on a local host, because you do not want your first plugin experiments touching a live site.
What a plugin really is
A plugin is a folder of PHP that WordPress loads on every page request, after the core files and before the theme. It has no special powers a theme lacks. What it has is a lifecycle: it can be activated, deactivated, and deleted independently of the design, and it survives theme changes. That is why anything that is behavior rather than presentation belongs in a plugin.
Plugins hook into WordPress rather than editing it. WordPress fires named events (actions) and passes values through named filters, and your plugin attaches callbacks to those names. You never modify a core file. Here are the hooks a first plugin usually needs.
| Hook | Type | Fires when | Typical use |
|---|---|---|---|
init | Action | WordPress is loaded, user is known | Register post types, taxonomies, shortcodes |
admin_menu | Action | Admin menu is being built | Add a settings screen |
admin_init | Action | Any admin request starts | Register settings, run admin only checks |
wp_enqueue_scripts | Action | Front end assets are queued | Load your CSS and JavaScript |
the_content | Filter | Post body is about to print | Append or modify post output |
wp_footer | Action | Just before </body> | Inject markup at the end of the page |
Create the folder and the plugin header
Every plugin lives in its own directory, and the convention is that the main file matches the directory name. Create wp-content/plugins/gb-notice-bar/gb-notice-bar.php. Pick a slug that nobody else is likely to use, because the slug becomes your namespace on WordPress.org later.
<?php
/**
* Plugin Name: GB Notice Bar
* Plugin URI: https://example.com/gb-notice-bar
* Description: Prints a short site wide notice in the footer.
* Version: 1.0.0
* Requires at least: 6.5
* Requires PHP: 7.4
* Author: Ethan Caldwell
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: gb-notice-bar
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'GBNB_VERSION', '1.0.0' );
define( 'GBNB_FILE', __FILE__ );Only Plugin Name: is strictly required. The rest is metadata WordPress shows in the Plugins list or uses to block activation on an unsupported PHP version. The ABSPATH guard matters: without it, anyone who requests your file directly over HTTP executes it outside WordPress, which is a classic source of fatal errors and worse. Save the file, open Plugins in the admin, and GB Notice Bar appears in the list. Activate it. Nothing happens yet, which is correct.
gbnb_. WordPress loads all active plugins into one global namespace, so an unprefixed function called get_settings() will eventually collide with somebody else’s and take the whole site down.Add your first hook
Now make the plugin do something. The pattern is always the same: write a function, then attach it to a hook with add_action() for events or add_filter() for values.
add_action( 'wp_footer', 'gbnb_render_bar' );
function gbnb_render_bar() {
$message = get_option( 'gbnb_message', '' );
if ( '' === trim( $message ) ) {
return;
}
printf(
'<div class="gbnb-bar" role="status">%s</div>',
esc_html( $message )
);
}Two details are doing real work here. get_option() reads a row from the wp_options table, which is where small settings belong because WordPress caches the autoloaded ones in a single query. And esc_html() escapes the value on the way out, so a stored message containing markup prints as text instead of executing.
Activation and deactivation hooks
Activation hooks run exactly once, at the moment the user clicks Activate. Use them for one time setup: seeding default options, creating a custom table, scheduling a cron event. Deactivation hooks run when the plugin is switched off and should undo anything that would keep running without the plugin, such as scheduled events.
register_activation_hook( GBNB_FILE, 'gbnb_activate' );
register_deactivation_hook( GBNB_FILE, 'gbnb_deactivate' );
function gbnb_activate() {
add_option( 'gbnb_message', '' );
add_option( 'gbnb_version', GBNB_VERSION );
if ( ! wp_next_scheduled( 'gbnb_daily_cleanup' ) ) {
wp_schedule_event( time(), 'daily', 'gbnb_daily_cleanup' );
}
}
function gbnb_deactivate() {
wp_clear_scheduled_hook( 'gbnb_daily_cleanup' );
}uninstall.php file, which only runs when the plugin is deleted.Use add_option() rather than update_option() when seeding defaults. add_option() does nothing if the row already exists, so reactivating the plugin will not wipe a value the site owner already set. The official reference for this behavior is in the WordPress Plugin Handbook.
Build a small settings page
The Settings API handles the form rendering, saving, nonce, and capability plumbing for you. Register the screen on admin_menu and the setting itself on admin_init.
add_action( 'admin_menu', 'gbnb_menu' );
add_action( 'admin_init', 'gbnb_register_settings' );
function gbnb_menu() {
add_options_page(
__( 'Notice Bar', 'gb-notice-bar' ),
__( 'Notice Bar', 'gb-notice-bar' ),
'manage_options',
'gb-notice-bar',
'gbnb_settings_page'
);
}
function gbnb_register_settings() {
register_setting(
'gbnb_group',
'gbnb_message',
array(
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
'default' => '',
)
);
}
function gbnb_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have permission to do this.', 'gb-notice-bar' ) );
}
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form method="post" action="options.php">
<?php settings_fields( 'gbnb_group' ); ?>
<input type="text" class="regular-text" name="gbnb_message"
value="<?php echo esc_attr( get_option( 'gbnb_message', '' ) ); ?>">
<?php submit_button(); ?>
</form>
</div>
<?php
}settings_fields() prints the nonce and the option group hidden fields, and options.php checks both before writing. That is why the Settings API is worth learning before you hand roll a form.
Security basics you cannot skip
Three rules cover most of what plugin reviewers reject. Check capability, verify intent, and sanitize on the way in while escaping on the way out.
add_action( 'admin_post_gbnb_reset', 'gbnb_handle_reset' );
function gbnb_handle_reset() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( 'Forbidden', 403 );
}
check_admin_referer( 'gbnb_reset_action' );
$note = isset( $_POST['note'] )
? sanitize_text_field( wp_unslash( $_POST['note'] ) )
: '';
update_option( 'gbnb_message', $note );
wp_safe_redirect( admin_url( 'options-general.php?page=gb-notice-bar' ) );
exit;
}Note wp_unslash() before sanitizing, because WordPress adds slashes to superglobals. Note also that escaping is chosen by context: esc_html() inside an element, esc_attr() inside an attribute, esc_url() for a link, and wp_kses_post() when you genuinely want to allow a limited set of tags. Never trust a value just because you wrote it to the database yourself.
$wpdb->prepare() with placeholders such as %d and %s. Interpolating a variable straight into SQL is the single most common way a plugin fails a security review.Where this tutorial ends and the next two begin
This piece is the on ramp: structure, hooks, one setting, safe output. Two companion articles pick up from here in different directions. If you want to see a single plugin built all the way through, including a shortcode, a block with block.json, translation loading, and an uninstall routine, read how to make a WordPress plugin that does something useful, which is one complete project rather than a set of concepts.
If you already write plugins and the question is how to structure a large one, building a WordPress plugin from scratch covers Composer autoloading, dependency injection instead of singletons, custom tables with dbDelta(), REST routes, and continuous integration. And when your plugin is ready for other people, submitting a plugin to the WordPress repository walks through readme.txt, the review queue, and the Subversion workflow.
Troubleshooting
The plugin does not appear in the Plugins list. WordPress only scans one level deep. The main PHP file has to sit at wp-content/plugins/your-plugin/your-plugin.php or directly in the plugins folder. If it is buried a second level down, or the header comment is missing the Plugin Name: line, or the file uses Windows line endings that broke the comment block, the plugin is invisible.
Activating gives a white screen or a fatal error. Turn on debugging in wp-config.php with define( 'WP_DEBUG', true ); and define( 'WP_DEBUG_LOG', true );, then read wp-content/debug.log. The usual causes are a PHP syntax error, a function name that collides with another plugin, or calling a WordPress function that is not loaded yet.
You are locked out and cannot deactivate from the admin. Rename the plugin folder over SFTP or in the file manager and WordPress deactivates it on the next request. Our guide to disabling a WordPress plugin from cPanel covers the steps if you only have hosting panel access.
The settings form saves nothing. The option name in register_setting() must match the name attribute on the input exactly, and the group passed to settings_fields() must match the first argument of register_setting(). A mismatch fails silently.
Your change does not show on the front end. Page caching, object caching, or a CDN is serving an old copy. Flush the site cache and hard reload, and confirm you are not viewing a static cached HTML file.
Frequently asked questions
Do I need to know object oriented PHP to write a plugin?
No. A single file of prefixed functions is perfectly valid and thousands of published plugins work that way. Classes become worthwhile once you have several features sharing state, or once you want autoloading and unit tests. Start procedural, then refactor when the file passes a few hundred lines.
What is the difference between an action and a filter?
An action is an event: WordPress announces that something is happening and your callback does work, returning nothing. A filter passes a value through your callback and expects the value back, changed or unchanged. Forgetting to return a value from a filter callback is the single most common beginner bug.
Where should I put my CSS and JavaScript?
Never inline them in the footer. Register them with wp_enqueue_style() and wp_enqueue_script() on the wp_enqueue_scripts hook, passing your plugin version as the version argument so browsers pick up changes. That lets WordPress deduplicate dependencies and lets caching plugins combine files correctly.
Can I edit plugin files in the WordPress admin?
You can, through the built in plugin editor, but you should not. There is no syntax check and no undo, so one typo produces a fatal error on a live site. Edit locally, use version control, and deploy. Many hosts disable the editor with DISALLOW_FILE_EDIT for exactly this reason.
How do I make my plugin translatable?
Wrap user facing strings in __() or esc_html__() with your text domain as the second argument, and keep the text domain identical to your plugin slug. Since WordPress 4.6, plugins hosted on WordPress.org get translations loaded automatically, so you usually do not need load_plugin_textdomain() at all.
The bottom line
A working plugin is a folder, a header comment, and a handful of hooks. The skill is not in the boilerplate; it is in knowing which hook fires at the right moment, prefixing everything so you do not collide with the rest of the site, and escaping output every single time you print it.
Build GB Notice Bar on a local install, break it deliberately, read the debug log, and fix it. Once that loop feels routine, move on to a complete project build or to the architecture patterns that keep a large plugin maintainable. If your goal is eventually to publish or sell what you build, the packaging and licensing decisions come later, and none of them are harder than the first fatal error you fix on your own.
