Welcome to the new GPLDL Site - all systems operational!

GPLDL
Search
Open menu
Tutorials

How to write your first WordPress Plugin from scratch?

A practical beginner guide to writing a secure WordPress plugin from scratch, including file structure, hooks, coding standards, a functional example plugin, testing, and security checks.

17 min read

GPLDL WordPress plugin development illustration

Writing a WordPress plugin from scratch is the fastest way to learn how WordPress really works. Themes control presentation. Plugins add behavior. If a feature should keep working after a site owner changes themes, it usually belongs in a plugin.

A first plugin does not need a build system, React, a custom database table, or a complex object model. It needs a clear purpose, a safe entry point, a correct plugin header, a few hooks, careful handling of data, and a repeatable way to test it.

This guide explains the essentials, then builds a small functional plugin that adds a configurable note and estimated reading time above single blog posts.

What a WordPress Plugin Is

At its simplest, a plugin is a PHP file with a plugin header comment. WordPress scans the wp-content/plugins directory for PHP files with that header and lists them on the Plugins screen.

In practice, a plugin can include:

  • PHP files for hooks, settings, admin screens, shortcodes, REST routes, cron jobs, and integrations.
  • CSS and JavaScript files loaded through WordPress enqueue functions.
  • Translation files.
  • Templates or view files.
  • Tests and development tooling.
  • A readme.txt file if the plugin will be submitted to WordPress.org.

The important idea is that a plugin changes or extends behavior without editing WordPress core.

Start With a Small Purpose

Before writing code, define the plugin in one sentence. If you cannot do that, the plugin is probably too broad for a first build.

Good first plugin ideas:

  • Add a configurable message before posts.
  • Register a shortcode.
  • Add a small settings page.
  • Modify post output with a filter.
  • Add metadata to posts.
  • Register a custom post type.
  • Add a custom REST endpoint.

Avoid these for your first plugin:

  • Payment processing.
  • User-role systems.
  • File upload systems.
  • Custom database-heavy applications.
  • Remote API integrations with stored secrets.
  • Complex block editor interfaces.

Those are valid plugin projects, but they multiply the security, testing, and maintenance surface.

Set Up a Local Development Environment

Build locally or on a staging site, never directly on production. You need a WordPress install where you can enable debugging, trigger errors, and reset data without harming a live site.

Use any local setup you are comfortable with: Local, DDEV, Docker, MAMP, XAMPP, Laravel Herd, WordPress Playground, or a dedicated staging server.

Enable debugging in wp-config.php:

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'WP_DEBUG_DISPLAY', false );
define( 'SCRIPT_DEBUG', true );

WP_DEBUG_LOG writes errors to wp-content/debug.log. WP_DEBUG_DISPLAY should usually stay off so notices do not break page output. SCRIPT_DEBUG is useful when you are working with JavaScript or CSS and want unminified core assets.

Create the Plugin Folder and Main File

Inside your WordPress install, create:

wp-content/plugins/first-post-note/
first-post-note.php

For a first plugin, a single PHP file is enough. When the plugin grows, move code into folders:

first-post-note/
first-post-note.php
uninstall.php
includes/
admin/
css/
js/
public/
css/
js/
languages/

The main plugin file belongs at the root of the plugin folder. Other code should be grouped by responsibility.

Add the Plugin Header

WordPress needs a header comment in the main plugin file. At minimum, it needs Plugin Name, but a real plugin should include more metadata.

<?php
/**
* Plugin Name: First Post Note
* Plugin URI: https://example.com/first-post-note
* Description: Adds a configurable note and reading time estimate above single posts.
* Version: 1.0.0
* Requires at least: 6.6
* Requires PHP: 8.1
* Author: Your Name
* Author URI: https://example.com
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: first-post-note
* Domain Path: /languages
*/

Important header fields:

  • Plugin Name is required.
  • Description appears on the Plugins screen.
  • Version should change for every release.
  • Requires at least and Requires PHP should reflect versions you actually test.
  • Text Domain should match the plugin slug and use lowercase words with hyphens.
  • License should be GPL-compatible if you plan to distribute through WordPress.org.
  • Update URI can be useful for private plugins so they are not accidentally overwritten by a similarly named WordPress.org plugin.
  • Requires Plugins can declare WordPress.org plugin dependencies when your plugin depends on another plugin.

Only one file in the plugin folder should have the plugin header.

Understand Hooks Before Writing Features

Hooks are how plugins connect to WordPress.

Actions let you do something at a certain point. Examples: register settings during admin_init, add a menu page during admin_menu, enqueue assets during wp_enqueue_scripts.

Filters let you receive a value, change it, and return it. Examples: modify post content with the_content, adjust excerpt output, change REST response data.

Use actions for side effects. Use filters for transforming a value and returning it.

add_action( 'admin_menu', 'first_post_note_add_settings_page' );
add_filter( 'the_content', 'first_post_note_prepend_note' );

The callback names are prefixed with the plugin slug. Prefixing matters because procedural PHP functions live in the global namespace. Without a prefix, another plugin can define the same function name and cause a fatal error.

Follow WordPress Coding Standards

WordPress Coding Standards are not only about visual style. They also encode conventions for interoperability, translation, database usage, and security.

For PHP, the most important habits are:

  • Use full PHP tags, not short tags.
  • Prefix global functions, classes, hooks, constants, option names, and handles.
  • Use lowercase function and variable names with underscores.
  • Use tabs for indentation.
  • Add spaces inside function call parentheses: my_function( $value ).
  • Use braces for control structures.
  • Use strict comparisons where practical.
  • Prefer WordPress APIs over direct database queries.
  • Escape output for the correct context.
  • Sanitize and validate input before saving or using it.
  • Internationalize user-facing strings with the plugin text domain.
  • Avoid clever code that makes review harder.

You can check many of these rules automatically with PHP_CodeSniffer and WordPress Coding Standards:

Terminal window
composer require --dev wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
vendor/bin/phpcs --standard=WordPress first-post-note.php

Automated checks do not replace manual review, but they catch many common mistakes early.

Build a Small Functional Plugin

The plugin below does five real things:

  • Creates default settings on activation.
  • Adds a Settings page under Settings > First Post Note.
  • Sanitizes saved settings.
  • Prepends a note and estimated reading time to single posts.
  • Deletes its option on uninstall.

Create wp-content/plugins/first-post-note/first-post-note.php and add:

<?php
/**
* Plugin Name: First Post Note
* Plugin URI: https://example.com/first-post-note
* Description: Adds a configurable note and reading time estimate above single posts.
* Version: 1.0.0
* Requires at least: 6.6
* Requires PHP: 8.1
* Author: Your Name
* Author URI: https://example.com
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: first-post-note
* Domain Path: /languages
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
define( 'FPN_OPTION_NAME', 'first_post_note_settings' );
/**
* Get default plugin settings.
*
* @return array<string, int|string>
*/
function first_post_note_default_settings() {
return array(
'enabled' => 1,
'message' => __( 'Thanks for reading. This article includes an estimated reading time.', 'first-post-note' ),
'words_per_minute' => 200,
);
}
/**
* Get saved settings merged with defaults.
*
* @return array<string, int|string>
*/
function first_post_note_get_settings() {
$saved = get_option( FPN_OPTION_NAME, array() );
if ( ! is_array( $saved ) ) {
$saved = array();
}
return wp_parse_args( $saved, first_post_note_default_settings() );
}
/**
* Add default settings when the plugin is activated.
*/
function first_post_note_activate() {
if ( false === get_option( FPN_OPTION_NAME, false ) ) {
add_option( FPN_OPTION_NAME, first_post_note_default_settings() );
}
}
register_activation_hook( __FILE__, 'first_post_note_activate' );
/**
* Delete plugin data when the plugin is uninstalled.
*/
function first_post_note_uninstall() {
delete_option( FPN_OPTION_NAME );
}
register_uninstall_hook( __FILE__, 'first_post_note_uninstall' );
/**
* Register plugin settings.
*/
function first_post_note_register_settings() {
register_setting(
'first_post_note_settings_group',
FPN_OPTION_NAME,
array(
'type' => 'array',
'sanitize_callback' => 'first_post_note_sanitize_settings',
'default' => first_post_note_default_settings(),
)
);
add_settings_section(
'first_post_note_main_section',
__( 'Display settings', 'first-post-note' ),
'first_post_note_render_section_intro',
'first-post-note'
);
add_settings_field(
'first_post_note_enabled',
__( 'Enable note', 'first-post-note' ),
'first_post_note_render_enabled_field',
'first-post-note',
'first_post_note_main_section'
);
add_settings_field(
'first_post_note_message',
__( 'Message', 'first-post-note' ),
'first_post_note_render_message_field',
'first-post-note',
'first_post_note_main_section'
);
add_settings_field(
'first_post_note_words_per_minute',
__( 'Words per minute', 'first-post-note' ),
'first_post_note_render_words_per_minute_field',
'first-post-note',
'first_post_note_main_section'
);
}
add_action( 'admin_init', 'first_post_note_register_settings' );
/**
* Sanitize settings before saving.
*
* @param mixed $input Raw settings.
* @return array<string, int|string>
*/
function first_post_note_sanitize_settings( $input ) {
$defaults = first_post_note_default_settings();
$input = is_array( $input ) ? $input : array();
$raw_message = isset( $input['message'] ) && is_scalar( $input['message'] ) ? (string) $input['message'] : $defaults['message'];
$raw_wpm = isset( $input['words_per_minute'] ) && is_scalar( $input['words_per_minute'] ) ? $input['words_per_minute'] : $defaults['words_per_minute'];
$message = sanitize_text_field( $raw_message );
$wpm = absint( $raw_wpm );
return array(
'enabled' => empty( $input['enabled'] ) ? 0 : 1,
'message' => $message,
'words_per_minute' => min( 600, max( 100, $wpm ) ),
);
}
/**
* Add the settings page.
*/
function first_post_note_add_settings_page() {
add_options_page(
__( 'First Post Note', 'first-post-note' ),
__( 'First Post Note', 'first-post-note' ),
'manage_options',
'first-post-note',
'first_post_note_render_settings_page'
);
}
add_action( 'admin_menu', 'first_post_note_add_settings_page' );
/**
* Render settings page intro text.
*/
function first_post_note_render_section_intro() {
echo '<p>' . esc_html__( 'Configure the note shown above single blog posts.', 'first-post-note' ) . '</p>';
}
/**
* Render the enabled checkbox.
*/
function first_post_note_render_enabled_field() {
$settings = first_post_note_get_settings();
?>
<label>
<input
type="checkbox"
name="<?php echo esc_attr( FPN_OPTION_NAME ); ?>[enabled]"
value="1"
<?php checked( 1, absint( $settings['enabled'] ) ); ?>
/>
<?php esc_html_e( 'Show the note above single posts.', 'first-post-note' ); ?>
</label>
<?php
}
/**
* Render the message field.
*/
function first_post_note_render_message_field() {
$settings = first_post_note_get_settings();
?>
<input
type="text"
class="regular-text"
name="<?php echo esc_attr( FPN_OPTION_NAME ); ?>[message]"
value="<?php echo esc_attr( $settings['message'] ); ?>"
/>
<?php
}
/**
* Render the words-per-minute field.
*/
function first_post_note_render_words_per_minute_field() {
$settings = first_post_note_get_settings();
?>
<input
type="number"
min="100"
max="600"
name="<?php echo esc_attr( FPN_OPTION_NAME ); ?>[words_per_minute]"
value="<?php echo esc_attr( absint( $settings['words_per_minute'] ) ); ?>"
/>
<?php
}
/**
* Render the settings page.
*/
function first_post_note_render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( 'first_post_note_settings_group' );
do_settings_sections( 'first-post-note' );
submit_button();
?>
</form>
</div>
<?php
}
/**
* Calculate an estimated reading time.
*
* @param string $content Post content.
* @param int $words_per_minute Reading speed.
* @return int
*/
function first_post_note_calculate_reading_time( $content, $words_per_minute ) {
$plain_text = wp_strip_all_tags( strip_shortcodes( $content ) );
$word_count = str_word_count( $plain_text );
$words_per_minute = max( 1, absint( $words_per_minute ) );
return max( 1, (int) ceil( $word_count / $words_per_minute ) );
}
/**
* Prepend the note to single post content.
*
* @param string $content Post content.
* @return string
*/
function first_post_note_prepend_note( $content ) {
if ( is_admin() || ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
$settings = first_post_note_get_settings();
if ( empty( $settings['enabled'] ) ) {
return $content;
}
$reading_time = first_post_note_calculate_reading_time(
get_post_field( 'post_content', get_the_ID() ),
absint( $settings['words_per_minute'] )
);
$reading_time_text = sprintf(
/* translators: %d: Estimated reading time in minutes. */
_n( 'Estimated reading time: %d minute.', 'Estimated reading time: %d minutes.', $reading_time, 'first-post-note' ),
$reading_time
);
$note = '<aside class="first-post-note" aria-label="' . esc_attr__( 'Post note', 'first-post-note' ) . '">';
$note .= '<p>' . esc_html( $settings['message'] ) . '</p>';
$note .= '<p>' . esc_html( $reading_time_text ) . '</p>';
$note .= '</aside>';
return $note . $content;
}
add_filter( 'the_content', 'first_post_note_prepend_note' );
/**
* Shortcode for displaying the current post reading time.
*
* @return string
*/
function first_post_note_reading_time_shortcode() {
if ( ! is_singular() ) {
return '';
}
$settings = first_post_note_get_settings();
$reading_time = first_post_note_calculate_reading_time(
get_post_field( 'post_content', get_the_ID() ),
absint( $settings['words_per_minute'] )
);
return esc_html(
sprintf(
/* translators: %d: Estimated reading time in minutes. */
_n( '%d minute read', '%d minutes read', $reading_time, 'first-post-note' ),
$reading_time
)
);
}
add_shortcode( 'first_post_note_reading_time', 'first_post_note_reading_time_shortcode' );

Activate the plugin from Plugins > Installed Plugins. Then go to Settings > First Post Note, change the message, and view a single post. You should see the note before the post content. You can also place this shortcode in post content:

[first_post_note_reading_time]

What the Example Gets Right

This small plugin demonstrates several important practices.

It blocks direct access:

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

This prevents someone from loading the PHP file directly outside WordPress.

It prefixes names:

first_post_note_prepend_note()
FPN_OPTION_NAME
first_post_note_settings

Prefixing reduces naming collisions with WordPress core, themes, and other plugins.

It uses the Options API instead of creating a table. A custom table is unnecessary for a small settings array.

It uses activation and uninstall hooks for lifecycle work. Activation creates default settings. Uninstall removes plugin-owned data. Deactivation is intentionally not used for deletion because deactivation may be temporary.

It uses the Settings API. The Settings API handles nonce generation through settings_fields(), routes saving through options.php, and runs the registered sanitization callback.

It checks capabilities:

if ( ! current_user_can( 'manage_options' ) ) {
return;
}

Capabilities protect privileged admin screens. A nonce is not permission. A nonce helps protect an intended user action from cross-site request forgery, but authorization still requires a capability check.

It sanitizes input before saving:

$message = sanitize_text_field( $raw_message );
$wpm = absint( $raw_wpm );

It validates the reading speed by clamping the value:

min( 600, max( 100, $wpm ) );

Sanitization cleans data. Validation decides whether the value is acceptable for the feature.

It escapes output for the correct context:

esc_html( $settings['message'] )
esc_attr__( 'Post note', 'first-post-note' )
esc_attr( FPN_OPTION_NAME )

Escape as late as possible, right before output.

It uses internationalization functions:

__( 'Display settings', 'first-post-note' )
esc_html__( 'Show the note above single posts.', 'first-post-note' )
_n( '%d minute read', '%d minutes read', $reading_time, 'first-post-note' )

Every user-facing string should include the plugin text domain.

Security Rules for First Plugins

Security is not a separate step after the plugin works. It is part of the design.

Never trust input

Treat everything as untrusted:

  • $_GET
  • $_POST
  • $_REQUEST
  • uploaded files
  • REST request data
  • shortcode attributes
  • block attributes
  • third-party API responses
  • saved options
  • post meta

Even administrators can paste broken or malicious data by mistake.

Sanitize, validate, escape

Use the right operation at the right time:

  • Sanitize input before saving or using it.
  • Validate values against allowed formats or ranges.
  • Escape output based on where it appears.

Examples:

  • Use sanitize_text_field() for simple text.
  • Use sanitize_email() for email addresses.
  • Use esc_html() for text inside HTML.
  • Use esc_attr() for HTML attributes.
  • Use esc_url() for URLs.
  • Use wp_kses_post() when limited post-style HTML is allowed.
  • Use absint() for positive integers.
  • Use a safelist for options such as sort order, layout type, or display mode.

Do not use escaping functions as sanitization functions. They solve different problems.

Use nonces for state-changing requests

If you create your own form handler, add a nonce:

wp_nonce_field( 'first_post_note_save_settings', 'first_post_note_nonce' );

Then verify it before processing:

if (
! isset( $_POST['first_post_note_nonce'] ) ||
! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['first_post_note_nonce'] ) ), 'first_post_note_save_settings' )
) {
wp_die( esc_html__( 'Security check failed.', 'first-post-note' ) );
}

For admin forms, check_admin_referer() is often the cleaner helper. For AJAX requests, use check_ajax_referer().

The Settings API already handles nonce fields for registered settings, which is why the example plugin does not manually process $_POST.

Check capabilities before privileged actions

For admin settings, manage_options is common. For post editing, use post-aware capabilities such as edit_post with the post ID. For file management, use the narrowest capability that matches the action.

Do not check roles such as “administrator” when a capability check would be more accurate. WordPress permissions are capability-based.

Avoid direct SQL unless necessary

Prefer WordPress APIs:

  • Options API for settings.
  • Metadata API for post, term, user, and comment metadata.
  • Transients API for temporary cached data.
  • WP_Query for posts.
  • REST API controllers for REST endpoints.

If you must write SQL, use $wpdb->prepare() with placeholders. WordPress 6.2 and later also supports %i for SQL identifiers such as table or column names.

Use WordPress path and URL helpers

Do not hard-code wp-content/plugins. Site owners can move or rename content directories.

Use:

  • plugin_dir_path( __FILE__ )
  • plugin_dir_url( __FILE__ )
  • plugins_url( 'file.js', __FILE__ )
  • plugin_basename( __FILE__ )

Load CSS and JavaScript with wp_enqueue_style() and wp_enqueue_script(), not hard-coded tags.

Be careful with privacy and external services

If your plugin sends data to a remote service, be explicit about it. Ask for consent where appropriate, document the data, and avoid sending more than the feature needs.

If the plugin stores personal data, plan for export and erasure support. WordPress includes privacy tools that plugins can integrate with.

Testing the Plugin

Test the plugin as a user, as a developer, and as someone trying to break it.

Manual checks

Run through this list:

  • Plugin appears on the Plugins screen.
  • Activation creates the default option.
  • Settings page appears only for users with manage_options.
  • Settings save successfully.
  • Message text is sanitized.
  • Words-per-minute value cannot save below 100 or above 600.
  • A single post displays the note.
  • Pages and archives do not get the note.
  • The shortcode returns a reading time.
  • Deactivation does not delete settings.
  • Uninstall deletes plugin settings.
  • No PHP warnings appear in debug.log.
  • No JavaScript console errors appear.

Try saving this as the message:

<script>alert("xss")</script><strong>Hello</strong>

The output should not execute JavaScript. In this plugin, the field is plain text, so HTML tags are stripped during sanitization and escaped during output.

Debugging tools

Use:

  • WP_DEBUG, WP_DEBUG_LOG, and WP_DEBUG_DISPLAY.
  • Query Monitor for PHP errors, hooks, conditionals, requests, and database queries.
  • Plugin Check for WordPress.org-oriented requirements and common best-practice issues.
  • PHP_CodeSniffer with WordPress Coding Standards.
  • Browser developer tools for network, console, and accessibility checks.

WP-CLI checks

If your local environment has WP-CLI, test common lifecycle commands:

Terminal window
wp plugin activate first-post-note
wp option get first_post_note_settings
wp plugin deactivate first-post-note
wp plugin uninstall first-post-note

If the plugin is already in the WordPress.org directory, wp plugin verify-checksums can verify installed files against official checksums. That is useful for released plugins, not for a new local plugin that has no directory release yet.

Automated tests

For a small first plugin, manual testing plus static analysis may be enough. As soon as the plugin becomes business-critical, add automated tests.

Useful test layers:

  • Unit tests for pure PHP helpers such as reading-time calculation.
  • Integration tests that load WordPress and test hooks, options, shortcodes, and capabilities.
  • End-to-end tests for admin screens and front-end behavior.
  • Static analysis through PHPCS and, for larger codebases, PHPStan or Psalm with WordPress stubs.

The more your plugin touches permissions, payments, files, user data, or database writes, the more testing it deserves.

Packaging and Release Checklist

Before sharing the plugin, check:

  • The plugin header is accurate.
  • Version number has been incremented.
  • License is GPL-compatible.
  • User-facing strings use the plugin text domain.
  • No development-only files are included unless intentional.
  • No API keys, credentials, logs, or local paths are included.
  • No direct file access problems remain.
  • All input is sanitized and validated.
  • All output is escaped.
  • Admin actions use capabilities and nonces.
  • Assets are enqueued correctly.
  • The plugin works on a clean WordPress install.
  • Uninstall behavior is documented and tested.
  • Plugin Check and PHPCS results are reviewed.

If submitting to WordPress.org, also prepare a valid readme.txt, assets, screenshots, stable tag, and support plan. Read the Plugin Directory guidelines before submission. They cover licensing, privacy, external services, dashboard notices, public links, and other review concerns.

Common Beginner Mistakes

Avoid these patterns:

  • Editing WordPress core files.
  • Putting plugin functionality in a theme.
  • Saving raw $_POST data.
  • Echoing saved options without escaping.
  • Assuming only administrators can reach an admin URL.
  • Using a nonce as a replacement for current_user_can().
  • Creating custom tables for simple settings.
  • Hard-coding plugin paths or URLs.
  • Loading scripts and styles on every page when only one screen needs them.
  • Using unprefixed function names.
  • Deleting user data on deactivation.
  • Hiding remote API calls or tracking behavior from users.
  • Shipping minified-only code that cannot be reviewed.

Most serious plugin problems come from a small number of habits: trusting input, skipping capability checks, skipping escaping, writing unsafe SQL, or mixing permanent data deletion with temporary deactivation.

Where to Go Next

After this first plugin works, extend it in one direction at a time:

  • Add a CSS file and enqueue it only on single posts.
  • Add a block that inserts the reading time.
  • Add a REST endpoint for reading-time data.
  • Add per-post override metadata.
  • Add unit tests for first_post_note_calculate_reading_time().
  • Split admin and public code into separate files.
  • Add translation files.
  • Run Plugin Check and PHPCS as part of CI.

Keep the plugin small until the structure needs to grow. WordPress supports large, complex plugins, but complexity should be earned by real requirements.

Useful Official References

Keep these official resources close:

Final Thoughts

Your first plugin should teach the core habits: hook into WordPress instead of editing core, keep functionality out of themes, prefix everything global, use WordPress APIs, sanitize and validate input, escape output, check capabilities, protect state-changing requests with nonces, and test the plugin lifecycle.

Once those habits are natural, bigger plugin development becomes much less mysterious. The same principles apply whether you are adding one shortcode or building a full commercial plugin.