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
/**
* 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:
wp plugin list --name=bakery-notice --fields=name,status,version+---------------+----------+---------+
| name | status | version |
+---------------+----------+---------+
| bakery-notice | inactive | 1.0.0 |
+---------------+----------+---------+wp plugin activate bakery-noticePlugin 'bakery-notice' activated.
Success: Activated 1 of 1 plugins.GET /Fresh bread every morning.Rules every plugin follows
- Block direct access with the
ABSPATHcheck at the top of every PHP file. - Prefix everything that is global: functions (
bakery_notice_print), classes, constants, option names, hook names, database tables. Two plugins definingprint_notice()would crash the site. A class (with a namespace) is the other way to avoid clashes. - Use hooks, never edit core files or other plugins.
- Make text translatable with
__(),_e()andesc_html__()and a text domain that matches the plugin's folder name. - 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
/**
* 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
// 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:
wp plugin deactivate bakery-notice
wp plugin activate bakery-notice
wp option get bakery_notice_textPlugin '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.
wp plugin uninstall bakery-notice --deactivateDeactivating '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:
wp option get bakery_notice_textError: 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:
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.orgLoad 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
ABSPATHchecks.
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
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.