Type something to search...
How to Write Your First WordPress Plugin?

How to Write Your First WordPress Plugin?

Most WordPress customization starts in the wrong place. A tutorial says "paste this into your theme's functions.php," and it works, until the theme updates and the change disappears, or the site switches themes and a feature the business relies on vanishes with it. Code that adds functionality to a site, rather than changing how it looks, belongs in a plugin. And a plugin is far less intimidating than it sounds: the smallest valid one is a single PHP file with a comment at the top.

This article walks through building a real plugin from that single file to something you could share: the plugin header, the folder structure, adding behavior with hooks, a settings page built with the Settings API, the security basics every plugin needs (escaping, sanitization, nonces, and capability checks), activation and uninstall routines, and translation readiness. The example plugin adds an estimated reading time to blog posts, with a settings screen to control it.

What a WordPress Plugin Actually Is

A plugin is PHP code that WordPress loads on every request, after core and before the theme. WordPress finds plugins by scanning wp-content/plugins for PHP files with a valid plugin header comment. Once a plugin is activated, its main file is included on every page load, and the plugin changes WordPress by registering hooks: callbacks that run at specific moments or that modify specific values.

Put code in...When
A pluginIt adds functionality: shortcodes, custom post types, integrations
A child themeIt changes presentation of a specific theme
A must-use pluginIt must always run and must not be deactivated from the admin
A code snippets pluginIt is a tiny tweak and you do not want to manage files

If you only need to restyle a theme, a child theme is the better tool, as covered in creating and customizing WordPress child themes. For everything else, write a plugin.

Setting Up a Safe Development Environment

Never write a plugin on a live site. A single PHP syntax error in an active plugin can take the whole site down with a critical error. Use a local environment such as wp-env, Docker, LocalWP, or WordPress Playground, and turn on debugging in wp-config.php so notices and warnings are visible:

<?php
// wp-config.php
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );

With these settings, PHP errors go to wp-content/debug.log instead of the screen. Keep the log open in a terminal with tail -f wp-content/debug.log while you work.

Step 1: Create the Plugin Folder and Header

Create a folder in wp-content/plugins named after your plugin. Use a unique, lowercase, hyphenated slug. This one is wsm-reading-time. Inside it, create a PHP file with the same name:

<?php
/**
 * Plugin Name:       WSM Reading Time
 * Plugin URI:        https://websolutionmaster.com/
 * Description:       Shows an estimated reading time on blog posts.
 * Version:           1.0.0
 * Requires at least: 6.5
 * Requires PHP:      7.4
 * Author:            Web Solution Master
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       wsm-reading-time
 * Domain Path:       /languages
 */

// wp-content/plugins/wsm-reading-time/wsm-reading-time.php

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

define( 'WSM_RT_VERSION', '1.0.0' );
define( 'WSM_RT_FILE', __FILE__ );
define( 'WSM_RT_DIR', plugin_dir_path( __FILE__ ) );

Go to Plugins in the admin. WSM Reading Time now appears in the list and can be activated, even though it does nothing yet.

The header fields that matter most:

  • Plugin Name is the only required field.
  • Requires at least and Requires PHP stop WordPress from activating the plugin on versions it does not support.
  • Text Domain must match the folder slug for translations to work.
  • License should be GPL-compatible if you ever publish to the WordPress.org directory.

The ABSPATH check stops the file from being run directly through a URL, which prevents information leaks and odd errors.

A Folder Structure That Scales

A one-file plugin is fine for small features. As soon as you add admin screens or assets, split the code:

# wp-content/plugins/wsm-reading-time/
wsm-reading-time.php      # Header, constants, bootstrapping
uninstall.php             # Cleanup when the plugin is deleted
includes/
  functions.php           # Reading time calculation and output
  settings.php            # Admin settings page
assets/
  reading-time.css        # Front-end styles
languages/                # Translation files
readme.txt                # Description for WordPress.org

Load the include files from the main plugin file:

<?php
// wp-content/plugins/wsm-reading-time/wsm-reading-time.php (continued)
require_once WSM_RT_DIR . 'includes/functions.php';

if ( is_admin() ) {
require_once WSM_RT_DIR . 'includes/settings.php';
}

Step 2: Add Behavior with Hooks

WordPress runs actions at specific moments (for example init or wp_enqueue_scripts) and filters that let you change a value before it is used (for example the_content). Your plugin registers callbacks with add_action() and add_filter(). Hooks are explained in depth in WordPress hooks explained: actions and filters. For now, the key idea is that a plugin never edits core files. It hooks in.

Calculating Reading Time

<?php
// wp-content/plugins/wsm-reading-time/includes/functions.php

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

/**
 * Default settings, used on activation and as a fallback.
 */
function wsm_rt_default_options() {
return array(
'words_per_minute' => 200,
'position'         => 'before',
'post_types'       => array( 'post' ),
);
}

/**
 * Returns saved options merged with defaults.
 */
function wsm_rt_get_options() {
$saved = get_option( 'wsm_rt_options', array() );
return wp_parse_args( is_array( $saved ) ? $saved : array(), wsm_rt_default_options() );
}

/**
* Estimated reading time in minutes for a post.
*/
function wsm_rt_get_minutes( $post = null ) {
$post = get_post( $post );
if ( ! $post ) {
return 0;
}

$options = wsm_rt_get_options();
$text    = wp_strip_all_tags( strip_shortcodes( $post->post_content ) );
$words   = str_word_count( $text );
$wpm     = max( 50, (int) $options['words_per_minute'] );

return max( 1, (int) ceil( $words / $wpm ) );
}

Every function name starts with wsm_rt_. WordPress loads all plugins into one global PHP namespace, so a generic name like get_minutes() will eventually collide with another plugin and cause a fatal error. Prefix everything: functions, options, hooks, CSS classes, and script handles. Alternatively, use a PHP namespace or a class, but still prefix option names and hook names.

Adding the Output to Post Content

<?php
// wp-content/plugins/wsm-reading-time/includes/functions.php (continued)

/**
 * Builds the reading time label.
 */
function wsm_rt_get_label( $post = null ) {
$minutes = wsm_rt_get_minutes( $post );

return sprintf(
/* translators: %d: number of minutes. */
_n( '%d minute read', '%d minutes read', $minutes, 'wsm-reading-time' ),
$minutes
);
}

/**
 * Adds reading time to the post content on single views.
 */
function wsm_rt_filter_content( $content ) {
if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) {
return $content;
}

$options = wsm_rt_get_options();
if ( ! in_array( get_post_type(), (array) $options['post_types'], true ) ) {
return $content;
}

$badge = sprintf(
'<p class="wsm-reading-time">%s</p>',
esc_html( wsm_rt_get_label() )
);

return 'after' === $options['position'] ? $content . $badge : $badge . $content;
}
add_filter( 'the_content', 'wsm_rt_filter_content' );

/**
 * Shortcode: [reading_time]
 */
function wsm_rt_shortcode() {
return sprintf( '<span class="wsm-reading-time">%s</span>', esc_html( wsm_rt_get_label() ) );
}
add_shortcode( 'reading_time', 'wsm_rt_shortcode' );

/**
 * Loads front-end styles only where they are needed.
 */
function wsm_rt_enqueue_assets() {
if ( ! is_singular() ) {
return;
}

wp_enqueue_style(
'wsm-reading-time',
plugins_url( 'assets/reading-time.css', WSM_RT_FILE ),
array(),
WSM_RT_VERSION
);
}
add_action( 'wp_enqueue_scripts', 'wsm_rt_enqueue_assets' );

A few habits in this code are worth copying in every plugin:

  • Filters must return a value. A the_content callback that forgets to return $content erases every post on the site.
  • Check the context. The is_singular(), in_the_loop(), and is_main_query() checks stop the badge from appearing in widgets, excerpts, and related-post lists that also run the_content.
  • Escape on output. esc_html() makes sure translated strings cannot inject HTML.
  • Enqueue assets properly. Never print link or script tags directly. wp_enqueue_style() lets WordPress deduplicate, order, and cache-bust files, and the version argument changes the URL when you release an update.

The stylesheet is tiny:

/* wp-content/plugins/wsm-reading-time/assets/reading-time.css */
.wsm-reading-time {
  font-size: 0.875rem;
  opacity: 0.75;
  margin-bottom: 1rem;
}

Step 3: Add a Settings Page with the Settings API

Hard-coded values are fine for a prototype, but site owners expect a settings screen. The Settings API handles the form, saving, and security for you: it registers a single option, outputs the hidden nonce fields, checks permissions, and runs your sanitization callback before anything reaches the database.

<?php
// wp-content/plugins/wsm-reading-time/includes/settings.php

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

function wsm_rt_add_settings_page() {
add_options_page(
__( 'Reading Time', 'wsm-reading-time' ),
__( 'Reading Time', 'wsm-reading-time' ),
'manage_options',
'wsm-reading-time',
'wsm_rt_render_settings_page'
);
}
add_action( 'admin_menu', 'wsm_rt_add_settings_page' );

function wsm_rt_register_settings() {
register_setting(
'wsm_rt_settings',
'wsm_rt_options',
array(
'type'              => 'array',
'sanitize_callback' => 'wsm_rt_sanitize_options',
'default'           => wsm_rt_default_options(),
)
);

add_settings_section( 'wsm_rt_main', __( 'Display', 'wsm-reading-time' ), '__return_false', 'wsm-reading-time' );

add_settings_field( 'words_per_minute', __( 'Words per minute', 'wsm-reading-time' ), 'wsm_rt_field_wpm', 'wsm-reading-time', 'wsm_rt_main' );
add_settings_field( 'position', __( 'Position', 'wsm-reading-time' ), 'wsm_rt_field_position', 'wsm-reading-time', 'wsm_rt_main' );
}
add_action( 'admin_init', 'wsm_rt_register_settings' );

function wsm_rt_sanitize_options( $input ) {
$defaults = wsm_rt_default_options();
$input    = is_array( $input ) ? $input : array();

return array(
'words_per_minute' => isset( $input['words_per_minute'] ) ? min( 1000, max( 50, absint( $input['words_per_minute'] ) ) ) : $defaults['words_per_minute'],
'position'         => ( isset( $input['position'] ) && in_array( $input['position'], array( 'before', 'after' ), true ) ) ? $input['position'] : $defaults['position'],
'post_types'       => $defaults['post_types'],
);
}

function wsm_rt_field_wpm() {
$options = wsm_rt_get_options();
printf(
'<input type="number" min="50" max="1000" name="wsm_rt_options[words_per_minute]" value="%d" class="small-text" />',
(int) $options['words_per_minute']
);
}

function wsm_rt_field_position() {
$options = wsm_rt_get_options();
?>
<select name="wsm_rt_options[position]">
<option value="before" <?php selected( $options['position'], 'before' ); ?>><?php esc_html_e( 'Before content', 'wsm-reading-time' ); ?></option>
<option value="after" <?php selected( $options['position'], 'after' ); ?>><?php esc_html_e( 'After content', 'wsm-reading-time' ); ?></option>
</select>
<?php
}

function wsm_rt_render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form action="options.php" method="post">
<?php
settings_fields( 'wsm_rt_settings' );
do_settings_sections( 'wsm-reading-time' );
submit_button();
?>
</form>
</div>
<?php
}

The new page appears under Settings → Reading Time. Notice what you did not have to write: no $_POST handling, no database queries, no nonce code. settings_fields() prints the nonce and option group fields, options.php verifies them and checks the user's capability, and your wsm_rt_sanitize_options() callback decides exactly what gets saved.

Step 4: The Security Basics Every Plugin Needs

Most plugin vulnerabilities come from skipping one of four checks. Learn them once and apply them everywhere.

RuleWhat it preventsFunctions to use
Sanitize inputBad or malicious data being savedsanitize_text_field(), absint(), sanitize_key(), allowlists
Escape outputCross-site scripting (XSS)esc_html(), esc_attr(), esc_url(), wp_kses_post()
Verify noncesCross-site request forgery (CSRF)wp_nonce_field(), check_admin_referer(), wp_verify_nonce()
Check capabilitiesUsers doing things they are not allowed tocurrent_user_can()

When you handle a form outside the Settings API, you must do all four yourself. Here is a "reset to defaults" button handled through admin-post.php:

<?php
// wp-content/plugins/wsm-reading-time/includes/settings.php (continued)

function wsm_rt_render_reset_form() {
?>
<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
<input type="hidden" name="action" value="wsm_rt_reset" />
<?php wp_nonce_field( 'wsm_rt_reset' ); ?>
<?php submit_button( __( 'Reset to defaults', 'wsm-reading-time' ), 'secondary' ); ?>
</form>
<?php
}

function wsm_rt_handle_reset() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You are not allowed to do this.', 'wsm-reading-time' ), 403 );
}

check_admin_referer( 'wsm_rt_reset' );

update_option( 'wsm_rt_options', wsm_rt_default_options() );

wp_safe_redirect( admin_url( 'options-general.php?page=wsm-reading-time&reset=1' ) );
exit;
}
add_action( 'admin_post_wsm_rt_reset', 'wsm_rt_handle_reset' );

Call wsm_rt_render_reset_form() at the end of wsm_rt_render_settings_page(), after the main form, because HTML forms cannot be nested. The admin_post_{action} hook only fires for logged-in users, check_admin_referer() stops forged requests, and current_user_can() stops logged-in users without permission. Always exit after a redirect.

Step 5: Activation, Deactivation, and Uninstall

WordPress gives plugins three lifecycle moments:

  • Activation runs once when the plugin is activated. Use it to set default options, create custom tables, or flush rewrite rules after registering post types.
  • Deactivation runs when the plugin is deactivated. Clear scheduled events and flush rewrite rules, but keep data, because the user may reactivate.
  • Uninstall runs when the user deletes the plugin. Remove everything the plugin stored.
<?php
// wp-content/plugins/wsm-reading-time/wsm-reading-time.php (continued)

function wsm_rt_activate() {
if ( false === get_option( 'wsm_rt_options' ) ) {
add_option( 'wsm_rt_options', wsm_rt_default_options() );
}
}
register_activation_hook( __FILE__, 'wsm_rt_activate' );

function wsm_rt_deactivate() {
// This plugin schedules no events. If yours does, clear them here:
// wp_clear_scheduled_hook( 'wsm_rt_daily_task' );
flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'wsm_rt_deactivate' );

register_activation_hook() must be called from the main plugin file, with __FILE__, and the callback must be defined by the time it runs, which is why functions.php is required at the top.

For uninstall, create uninstall.php in the plugin root. WordPress runs it only when the user deletes the plugin from the admin:

<?php
// wp-content/plugins/wsm-reading-time/uninstall.php

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

delete_option( 'wsm_rt_options' );

The WP_UNINSTALL_PLUGIN check makes sure the file only runs during a real uninstall. On multisite, loop through sites with get_sites() and switch_to_blog() if you stored per-site options.

Step 6: Make the Plugin Translation Ready

Every user-facing string in the examples is wrapped in a translation function with the wsm-reading-time text domain: __(), esc_html__(), esc_html_e(), and _n() for plurals. Since WordPress 4.6, plugins hosted on WordPress.org load translations automatically from translate.wordpress.org, so you do not need to call load_plugin_textdomain() for them. For a private plugin that ships its own .mo files in languages/, load them on init:

<?php
// wp-content/plugins/wsm-reading-time/wsm-reading-time.php (continued)
function wsm_rt_load_textdomain() {
load_plugin_textdomain( 'wsm-reading-time', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
}
add_action( 'init', 'wsm_rt_load_textdomain' );

Since WordPress 6.7, translation functions called before init trigger a "translation loading was triggered too early" notice. Keep translated strings inside functions that run on or after init, never at the top level of a file.

Step 7: Test, Package, and Share

Before you call the plugin done:

  1. Test with debugging on and confirm debug.log stays empty when you activate, use, deactivate, and delete the plugin.
  2. Test with a default theme such as Twenty Twenty-Five, and with the block editor and classic editor if relevant.
  3. Run Plugin Check, the official plugin from the WordPress.org team, which flags missing escaping, unprefixed names, and other issues reviewers look for.
  4. Write a readme.txt in the WordPress.org format with a description, installation steps, and a changelog.
  5. Zip the folder, not its contents, so it installs through Plugins → Add New → Upload Plugin.

When you release updates, bump the Version header and the WSM_RT_VERSION constant together, so cached CSS and JavaScript files are refreshed. Keeping plugins current on live sites is covered in how to update WordPress plugins and themes.

Common Problems and Fixes

  • "The plugin does not have a valid header." The header comment is missing, misspelled, or the main file is nested one folder too deep. The PHP file with the header must be directly inside the plugin's folder.
  • White screen or critical error after activation. A PHP syntax error or a duplicate function name. Check wp-content/debug.log, then rename the plugin folder over FTP or SSH to deactivate it.
  • "Cannot redeclare function." Another plugin or theme uses the same function name. Prefix every name, or wrap optional functions in function_exists() checks.
  • Settings do not save. The register_setting() group name does not match settings_fields(), or the sanitize callback returns nothing.
  • Output appears in the wrong places. A the_content filter without context checks also runs in excerpts, widgets, and feeds. Add is_singular(), in_the_loop(), and is_main_query().
  • Activation hook never runs. It was registered inside another hook such as init, or from a file other than the main plugin file. Register it at the top level of the main file.

Writing Your First WordPress Plugin FAQ

No. Many well-known plugins started as a set of prefixed functions, like the example in this article. Classes and namespaces help organize larger plugins, but they are not required to write a correct, secure plugin.

Put it in a plugin if it adds functionality you want to keep regardless of the active theme, such as shortcodes, post types, or integrations. Use a child theme's functions.php only for changes that are specific to how that theme looks.

Deactivation turns the plugin off but keeps its data, because the user may turn it back on. Uninstall happens when the user deletes the plugin, and is the moment to remove options, custom tables, and other data the plugin created.

All plugins and the active theme share one global PHP namespace. Two functions with the same name cause a fatal error that can take down the site. A unique prefix, a PHP namespace, or a class avoids those collisions.

Create a WordPress.org account, prepare a readme.txt, run the Plugin Check tool, and submit a zip through the plugin submission page. After a manual review, you get access to a Subversion repository where you publish releases.

Conclusion

A WordPress plugin is not a special kind of code. It is a PHP file with a header comment that hooks into WordPress at the right moments. From that starting point, a well-built plugin adds a few habits: prefix every name, check context before changing output, use the Settings API instead of handling forms by hand, sanitize input, escape output, verify nonces and capabilities, clean up after itself on uninstall, and wrap strings for translation.

The reading time plugin in this article uses all of those habits in a few hundred lines of code. Use it as a template for your next feature, and you will end up with code that survives theme changes, plays well with other plugins, and passes a security review.

Here are some useful references for going deeper on plugin development:

  1. WordPress Developer Resources: Plugin Handbook — the official guide to plugin development.
  2. WordPress Developer Resources: Header Requirements — every supported plugin header field.
  3. WordPress Developer Resources: Settings API — registering settings, sections, and fields.
  4. WordPress Developer Resources: Plugin Security — sanitizing, escaping, nonces, and capabilities.
  5. WordPress.org Plugins: Plugin Check — the official tool for checking plugins against WordPress.org guidelines.
Tags :
Share :

Related Posts

WordPress optimization with specific recommended approach

WordPress optimization with specific recommended approach

Whether you run a high traffic WordPress installation or a small blog on a low cost shared host, you should optimize WordPress and your server to run

Continue Reading
Creating and Customizing WordPress Child Themes

Creating and Customizing WordPress Child Themes

Creating a child theme in WordPress is a best practice for making modifications to a theme. By using a child theme, you can update the parent theme w

Continue Reading
Understanding the Distinction Categories vs. Tags in WordPress

Understanding the Distinction Categories vs. Tags in WordPress

WordPress, a powerful content management system, offers a plethora of features to organize content effectively. Among these features, categories and

Continue Reading