Himanshu SharmaSoftware engineer
Let’s talk

WordPress · Lesson 29 of 45

Writing your first plugin

Create a WordPress plugin from scratch with a plugin header, activation and deactivation hooks, a safe file structure and a prefix for everything you define.

14 minHooks and pluginsUpdated 23 October 2026

By the end of this lessonBuild, activate and structure a small plugin that adds a feature without touching the theme, and know the rules that keep it safe.

Plugins versus themes

A theme controls how a site looks. A plugin adds features. If you put a feature (a custom post type, a shortcode, an analytics snippet) in the theme's functions.php, it disappears when you change the theme. In a plugin it stays. Plugins are also how you share code with others.

A plugin is just PHP that uses hooks.

The smallest plugin

Create a folder for the plugin and a main file with the same name:

php
<?php
/**
 * Plugin Name:       Bakery Notice
 * Description:       Shows a notice at the end of every page.
 * Version:           1.0.0
 * Requires at least: 6.5
 * Requires PHP:      8.0
 * Author:            Your Name
 * License:           GPL-2.0-or-later
 * Text Domain:       bakery-notice
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;   // Do nothing if the file is requested directly.
}

function bakery_notice_print() {
	echo '<p class="bakery-notice">' . esc_html__( 'Fresh bread every morning.', 'bakery-notice' ) . '</p>';
}
add_action( 'wp_footer', 'bakery_notice_print' );

The header comment is how WordPress finds the plugin. Only Plugin Name is required; the others show in the Plugins screen and help with updates and compatibility. WordPress lists it now:

bash
wp plugin list --name=bakery-notice --fields=name,status,version
result
+---------------+----------+---------+
| name          | status   | version |
+---------------+----------+---------+
| bakery-notice | inactive | 1.0.0   |
+---------------+----------+---------+
bash
wp plugin activate bakery-notice
result
Plugin 'bakery-notice' activated.
Success: Activated 1 of 1 plugins.
http
GET /
result
Fresh bread every morning.

Rules every plugin follows

  1. Block direct access with the ABSPATH check at the top of every PHP file.
  2. Prefix everything that is global: functions (bakery_notice_print), classes, constants, option names, hook names, database tables. Two plugins defining print_notice() would crash the site. A class (with a namespace) is the other way to avoid clashes.
  3. Use hooks, never edit core files or other plugins.
  4. Make text translatable with __(), _e() and esc_html__() and a text domain that matches the plugin's folder name.
  5. Escape output and validate input (see Writing secure WordPress code).

Activation, deactivation and uninstall

Plugins often need to set something up once, for example default options. WordPress gives three moments:

php
<?php
/**
 * Plugin Name:       Bakery Notice
 * Description:       Shows a notice at the end of every page.
 * Version:           1.0.0
 * Text Domain:       bakery-notice
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

define( 'BAKERY_NOTICE_VERSION', '1.0.0' );

function bakery_notice_print() {
	$text = get_option( 'bakery_notice_text', 'Fresh bread every morning.' );
	echo '<p class="bakery-notice">' . esc_html( $text ) . '</p>';
}
add_action( 'wp_footer', 'bakery_notice_print' );

// Runs once when the plugin is activated.
register_activation_hook( __FILE__, function () {
	add_option( 'bakery_notice_text', 'Fresh bread every morning.' );
} );

// Runs when it is deactivated: undo temporary things (scheduled events, rewrite rules), keep the user's data.
register_deactivation_hook( __FILE__, function () {
	flush_rewrite_rules();
} );
php
<?php
// Runs only when the plugin is deleted from the Plugins screen.
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
	exit;
}
delete_option( 'bakery_notice_text' );

Try the cycle with WP-CLI:

bash
wp plugin deactivate bakery-notice
wp plugin activate bakery-notice
wp option get bakery_notice_text
result
Plugin 'bakery-notice' deactivated.
Success: Deactivated 1 of 1 plugins.
Plugin 'bakery-notice' activated.
Success: Activated 1 of 1 plugins.
Fresh bread every morning.
  • Activation is the place to create options or tables. It does not run on every page load, so it must not be your only setup.
  • Deactivation should leave the user's data alone, so reactivating restores everything.
  • Uninstall (uninstall.php) is for removing the data when the plugin is deleted.
bash
wp plugin uninstall bakery-notice --deactivate
result
Deactivating 'bakery-notice'...
Plugin 'bakery-notice' deactivated.
Uninstalled and deleted 'bakery-notice' plugin.
Success: Uninstalled 1 of 1 plugins.

Check that the uninstall script removed the option. WP-CLI reports an error when an option does not exist:

bash
wp option get bakery_notice_text
result
Error: Could not get 'bakery_notice_text' option. Does it exist?

Structure of a larger plugin

As a plugin grows, split it into files. A common layout:

text
bakery-notice/
  bakery-notice.php      main file: header, constants, loads the rest
  uninstall.php
  includes/              PHP classes and functions
  admin/                 settings pages
  assets/                css, js, images
  languages/             translation files
  readme.txt             description for wordpress.org

Load files with require_once plugin_dir_path( __FILE__ ) . 'includes/class-notice.php'; and refer to assets with plugins_url( 'assets/notice.css', __FILE__ ).

Common mistakes

  • Using generic names such as function init().
  • Doing database work or heavy checks on every request instead of once on activation.
  • Deleting the user's data on deactivation.
  • Putting a closing ?> at the end of a PHP-only file, where stray whitespace after it can cause "headers already sent" errors.
  • Forgetting ABSPATH checks.

Exercise

Add a second hook to the plugin: a filter on the_content that appends the text "Thanks for reading." to single posts only (hint: is_single()).

Show one possible answer
php
add_filter( 'the_content', function ( string $content ): string {
	if ( is_single() && in_the_loop() && is_main_query() ) {
		$content .= '<p>' . esc_html__( 'Thanks for reading.', 'bakery-notice' ) . '</p>';
	}
	return $content;
} );

in_the_loop() and is_main_query() avoid adding the text to other places where the_content runs, such as widgets and feeds.

Key points

  • A plugin is a PHP file (or folder) in wp-content/plugins/ with a comment header that includes at least Plugin Name.
  • Put features in plugins, not in the theme's functions.php, so they survive a theme change.
  • Prefix every function, class, option and hook with something unique, and stop direct access with ABSPATH.
  • register_activation_hook() runs once on activation, register_deactivation_hook() on deactivation, and uninstall.php when the plugin is deleted.

Check yourself

1. What makes WordPress recognise a file as a plugin?
2. Why prefix function names such as bakery_greeting()?
3. Where should a plugin remove its database options when the user deletes it?
4. What is the line if ( ! defined( 'ABSPATH' ) ) { exit; } for?