Type something to search...
How to create a WordPress plugin from scratch?

How to create a WordPress plugin from scratch?

To create a WordPress plugin from scratch, you make a folder inside wp-content/plugins/, add a PHP file with a special comment block called the plugin header, and then use WordPress hooks to add your functionality. Once the header is in place, the plugin appears on the Plugins screen and you can activate it like any other. Everything beyond that, from settings pages to shortcodes, is just PHP built on WordPress APIs.

In this guide you will build a small but complete plugin step by step. You will learn how to structure the files, write the header, use hooks, add a settings page, handle activation and uninstall, secure your code, and prepare the plugin for translation. By the end you will have a solid template you can reuse for your own projects.

What Is a WordPress Plugin?

A plugin is a package of code that adds or changes functionality on a WordPress site without editing core files. Plugins can be as small as a single function that tweaks the login page or as large as a full e-commerce platform.

Plugins work by attaching functions to WordPress hooks (actions and filters). When WordPress loads, it includes every active plugin's main file, and your code registers itself at the points where it needs to run.

Why Build Your Own Plugin?

You might wonder why you should create a plugin instead of adding code to your theme's functions.php. Here are the main reasons:

  • Theme independence: Plugin code keeps working if you switch themes.
  • Organization: Related functionality lives in one place, with its own files and assets.
  • Portability: You can install the same plugin on multiple sites.
  • Easy toggling: You can deactivate a plugin in one click if something goes wrong.
  • Distribution: You can share it with clients or submit it to the WordPress.org Plugin Directory.

A good rule of thumb: if the code controls how your site looks, it belongs in the theme. If it controls what your site does, it belongs in a plugin.

What You Need Before You Start

  1. A local development environment: Tools such as Local, DDEV, or wp-env let you run WordPress on your computer safely.
  2. A code editor: Visual Studio Code, PhpStorm, or any editor with PHP syntax highlighting.
  3. Basic PHP knowledge: Functions, arrays, and conditionals are enough to begin.
  4. Debug mode enabled: Set WP_DEBUG to true in wp-config.php on your local site so you see errors immediately.

Never develop a new plugin directly on a live site. If you must test on production, take a full backup first.

Step 1: Create the Plugin Folder and Main File

Go to wp-content/plugins/ and create a folder with a unique, lowercase, hyphenated name. For this tutorial we will build a "Reading Time" plugin that shows an estimated reading time above posts.

cd wp-content/plugins
mkdir sajjad-reading-time
cd sajjad-reading-time
touch sajjad-reading-time.php

Naming the main file the same as the folder is a common convention and makes the plugin easy to identify.

Step 2: Add the Plugin Header

Open sajjad-reading-time.php and add the header comment. WordPress reads this block to display your plugin on the Plugins screen.

<?php
/**
 * Plugin Name:       Sajjad Reading Time
 * Plugin URI:        https://example.com/reading-time
 * Description:       Displays an estimated reading time at the top of blog posts.
 * Version:           1.0.0
 * Requires at least: 6.4
 * Requires PHP:      8.0
 * Author:            Sajjad
 * Author URI:        https://sajjad.me
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       sajjad-reading-time
 * Domain Path:       /languages
 */

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

Only Plugin Name is strictly required, but the other fields help users and WordPress understand compatibility. The ABSPATH check prevents anyone from running the file directly through a browser.

Now go to Plugins > Installed Plugins in your dashboard. You should see "Sajjad Reading Time" listed. Activate it. It does nothing yet, but WordPress now loads it on every request.

Step 3: Plan a Clean File Structure

Small plugins can live in one file, but a structure like this scales much better:

sajjad-reading-time/
├── sajjad-reading-time.php
├── uninstall.php
├── includes/
│   ├── class-reading-time.php
│   └── class-settings.php
├── assets/
│   └── css/
│       └── reading-time.css
├── languages/
└── readme.txt

Define a few constants in the main file so you can reference paths and the version easily:

define( 'SAJJAD_RT_VERSION', '1.0.0' );
define( 'SAJJAD_RT_PATH', plugin_dir_path( __FILE__ ) );
define( 'SAJJAD_RT_URL', plugin_dir_url( __FILE__ ) );

require_once SAJJAD_RT_PATH . 'includes/class-reading-time.php';
require_once SAJJAD_RT_PATH . 'includes/class-settings.php';

Step 4: Add the Core Functionality with Hooks

Create includes/class-reading-time.php. This class calculates the reading time and adds it before the post content using the the_content filter.

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

class Sajjad_Reading_Time {

public function __construct() {
add_filter( 'the_content', array( $this, 'add_reading_time' ) );
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_styles' ) );
}

public static function calculate( $content ) {
$words_per_minute = (int) get_option( 'sajjad_rt_wpm', 200 );
$words_per_minute = max( 50, $words_per_minute );

$word_count = str_word_count( wp_strip_all_tags( $content ) );
$minutes    = (int) ceil( $word_count / $words_per_minute );

return max( 1, $minutes );
}

public function add_reading_time( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}

$minutes = self::calculate( $content );

$label = sprintf(
/* translators: %d: number of minutes. */
_n( '%d minute read', '%d minutes read', $minutes, 'sajjad-reading-time' ),
$minutes
);

$badge = '<p class="sajjad-reading-time">' . esc_html( $label ) . '</p>';

return $badge . $content;
}

public function enqueue_styles() {
if ( ! is_singular( 'post' ) ) {
return;
}

wp_enqueue_style(
'sajjad-reading-time',
SAJJAD_RT_URL . 'assets/css/reading-time.css',
array(),
SAJJAD_RT_VERSION
);
}
}

And add a little styling in assets/css/reading-time.css:

.sajjad-reading-time {
  display: inline-block;
  margin-bottom: 1.5rem;
  padding: 0.25rem 0.75rem;
  border-radius: 999px;
  background: #f1f5f9;
  color: #334155;
  font-size: 0.875rem;
}

A few things to notice:

  • The conditions in add_reading_time() make sure the badge only appears on single posts in the main loop.
  • Output is escaped with esc_html().
  • _n() handles singular and plural forms for translation.
  • Styles only load on single posts, so they do not add weight to other pages.

Step 5: Initialize the Plugin

Back in the main file, start the plugin once all plugins have loaded:

function sajjad_rt_init() {
new Sajjad_Reading_Time();

if ( is_admin() ) {
new Sajjad_RT_Settings();
}
}
add_action( 'plugins_loaded', 'sajjad_rt_init' );

Using plugins_loaded ensures other plugins your code might interact with are available before yours runs.

Step 6: Add a Settings Page

Most plugins need a few options. WordPress provides the Settings API to build option pages that handle saving, nonces, and validation for you. Create includes/class-settings.php:

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

class Sajjad_RT_Settings {

public function __construct() {
add_action( 'admin_menu', array( $this, 'add_menu' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
}

public function add_menu() {
add_options_page(
__( 'Reading Time', 'sajjad-reading-time' ),
__( 'Reading Time', 'sajjad-reading-time' ),
'manage_options',
'sajjad-reading-time',
array( $this, 'render_page' )
);
}

public function register_settings() {
register_setting(
'sajjad_rt_settings',
'sajjad_rt_wpm',
array(
'type'              => 'integer',
'sanitize_callback' => 'absint',
'default'           => 200,
)
);

add_settings_section(
'sajjad_rt_main',
__( 'General', 'sajjad-reading-time' ),
'__return_false',
'sajjad-reading-time'
);

add_settings_field(
'sajjad_rt_wpm',
__( 'Words per minute', 'sajjad-reading-time' ),
array( $this, 'render_wpm_field' ),
'sajjad-reading-time',
'sajjad_rt_main'
);
}

public function render_wpm_field() {
$value = (int) get_option( 'sajjad_rt_wpm', 200 );
printf(
'<input type="number" name="sajjad_rt_wpm" value="%d" min="50" max="1000" class="small-text" />',
esc_attr( $value )
);
}

public function render_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( 'sajjad_rt_settings' );
do_settings_sections( 'sajjad-reading-time' );
submit_button();
?>
</form>
</div>
<?php
}
}

After saving, visit Settings > Reading Time and you can change the words-per-minute value. settings_fields() outputs the nonce and hidden fields that options.php needs, and register_setting() runs your sanitize callback before saving.

Adding a Settings Link on the Plugins Screen

A small touch that users appreciate is a "Settings" link next to "Deactivate":

function sajjad_rt_action_links( $links ) {
$url           = admin_url( 'options-general.php?page=sajjad-reading-time' );
$settings_link = '<a href="' . esc_url( $url ) . '">' . esc_html__( 'Settings', 'sajjad-reading-time' ) . '</a>';
array_unshift( $links, $settings_link );
return $links;
}
add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'sajjad_rt_action_links' );

Step 7: Handle Activation and Deactivation

Some plugins need to set things up when activated, such as default options or custom database tables, and clean up temporary data on deactivation. Register these hooks in the main plugin file:

function sajjad_rt_activate() {
if ( false === get_option( 'sajjad_rt_wpm' ) ) {
add_option( 'sajjad_rt_wpm', 200 );
}
}
register_activation_hook( __FILE__, 'sajjad_rt_activate' );

function sajjad_rt_deactivate() {
// Clear scheduled events or transients here if you add any.
wp_clear_scheduled_hook( 'sajjad_rt_daily_event' );
}
register_deactivation_hook( __FILE__, 'sajjad_rt_deactivate' );

If your plugin registers custom post types or rewrite rules, call flush_rewrite_rules() on activation after registering them, so permalinks work immediately.

Step 8: Clean Up on Uninstall

Deactivation is temporary. Uninstalling (deleting the plugin) should remove everything your plugin stored. Create uninstall.php in the plugin root:

<?php
if ( ! defined( 'WP_UNINSTALL_PLUGIN' ) ) {
exit;
}

delete_option( 'sajjad_rt_wpm' );

WordPress runs this file automatically when a user deletes the plugin from the Plugins screen. The WP_UNINSTALL_PLUGIN check ensures it cannot be triggered any other way.

Step 9: Adding a Shortcode or Block

You may want to let users place the reading time anywhere. A shortcode is the quickest option:

function sajjad_rt_shortcode() {
$post = get_post();

if ( ! $post ) {
return '';
}

$minutes = Sajjad_Reading_Time::calculate( $post->post_content );

return '<span class="sajjad-reading-time">' . esc_html(
sprintf(
/* translators: %d: number of minutes. */
_n( '%d minute read', '%d minutes read', $minutes, 'sajjad-reading-time' ),
$minutes
)
) . '</span>';
}
add_shortcode( 'reading_time', 'sajjad_rt_shortcode' );

For a modern experience in the block editor and Site Editor, you can go further and register a custom block with block.json and register_block_type(). That is a bigger topic with its own tooling, but it fits neatly into the same plugin structure.

Security Essentials for Plugin Developers

Security is not optional. Every plugin should follow these rules:

  1. Sanitize input: Clean data as it comes in with functions such as sanitize_text_field(), absint(), sanitize_email(), and esc_url_raw().
  2. Escape output: Escape data as late as possible when printing it, with esc_html(), esc_attr(), esc_url(), or wp_kses_post().
  3. Check capabilities: Use current_user_can() before letting anyone change settings or data.
  4. Verify nonces: For custom forms and AJAX handlers, create a nonce with wp_nonce_field() or wp_create_nonce() and verify it with check_admin_referer() or check_ajax_referer().
  5. Use prepared queries: If you query the database directly, always use $wpdb->prepare().

Here is a custom form handler that follows all of these rules:

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

check_admin_referer( 'sajjad_rt_reset_action', 'sajjad_rt_reset_nonce' );

update_option( 'sajjad_rt_wpm', 200 );

wp_safe_redirect( admin_url( 'options-general.php?page=sajjad-reading-time&reset=1' ) );
exit;
}
add_action( 'admin_post_sajjad_rt_reset', 'sajjad_rt_handle_reset' );

And the matching form:

<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
<input type="hidden" name="action" value="sajjad_rt_reset" />
<?php wp_nonce_field( 'sajjad_rt_reset_action', 'sajjad_rt_reset_nonce' ); ?>
<?php submit_button( __( 'Reset to default', 'sajjad-reading-time' ), 'secondary' ); ?>
</form>

Making Your Plugin Translatable

Wrap every user-facing string in a translation function such as __(), _e(), esc_html__(), or _n(), always using your text domain. Since WordPress 4.6, plugins hosted on WordPress.org have their translations loaded automatically. If you ship your own .mo files in the languages folder, load them on init:

function sajjad_rt_load_textdomain() {
load_plugin_textdomain(
'sajjad-reading-time',
false,
dirname( plugin_basename( __FILE__ ) ) . '/languages'
);
}
add_action( 'init', 'sajjad_rt_load_textdomain' );

You can generate a .pot template with WP-CLI:

wp i18n make-pot . languages/sajjad-reading-time.pot

Best Practices for Plugin Development

Prefix everything: Functions, classes, options, constants, and hooks should all share a unique prefix to avoid conflicts with other plugins.

Load only what you need: Enqueue assets conditionally and avoid running expensive code on every request.

Follow the WordPress Coding Standards: Tools like PHP_CodeSniffer with the WordPress ruleset catch many style and security issues automatically.

Use version control: Keep your plugin in Git so you can track changes and roll back mistakes.

Test with debugging on: Develop with WP_DEBUG enabled and fix every notice and warning.

Check compatibility: Test against current versions of WordPress and PHP before each release, and keep the Requires at least and Requires PHP header values accurate.

Write a readme.txt: If you plan to publish on WordPress.org, the directory uses this file for your plugin page, including the description, FAQ, changelog, and "Tested up to" value.

Submitting to the WordPress.org Plugin Directory

If you want to share your plugin publicly:

  1. Review the guidelines: Read the Detailed Plugin Guidelines on WordPress.org. Your plugin must be GPL-compatible and must not include tracking without consent.
  2. Run Plugin Check: The official Plugin Check plugin scans for common problems before you submit.
  3. Submit the ZIP: Upload it through the "Add Your Plugin" page while logged in to your WordPress.org account.
  4. Wait for review: A volunteer team reviews your code. Review times vary.
  5. Use SVN: Once approved, you publish releases through the Subversion repository provided to you.

FAQ: Creating a WordPress Plugin

A single PHP file inside wp-content/plugins with a comment header that includes at least the Plugin Name line. WordPress will then list it on the Plugins screen.

Use a plugin for functionality that should survive a theme change, such as custom post types or integrations. Use your child theme's functions.php for code tied to the theme's design.

No. Many plugins use simple functions. Classes help organize larger plugins, but procedural code with good prefixes works perfectly well.

Deactivation turns the plugin off but keeps its data in case it is reactivated. Uninstall happens when the plugin is deleted and should remove its options, tables, and other stored data.

Enable WP_DEBUG and WP_DEBUG_LOG in wp-config.php, check wp-content/debug.log for errors, and install Query Monitor to inspect hooks, queries, and PHP notices.

Yes. WordPress plugins must be GPL-compatible, but you can still charge for downloads, support, and updates. Many developers offer a free version on WordPress.org and a premium version on their own site.

Scaffold a block with the @wordpress/create-block package, then register it in your plugin with register_block_type pointing to the block.json file in your build folder.


Conclusion

Creating a WordPress plugin from scratch is less intimidating than it looks. A folder, a PHP file with a header, and a few well-chosen hooks are all you need to start. From there, you can add a settings page with the Settings API, handle activation and uninstall cleanly, and grow your code into a structured, reusable package.

Take the Reading Time example from this guide and make it your own: change the output, add more settings, or turn it into a block. Keep security at the front of your mind by sanitizing, escaping, checking capabilities, and verifying nonces. With those habits in place, every plugin you build will be safer, faster, and easier to maintain.

Tags :
Share :

Related Posts

What are the best WordPress security plugins?

What are the best WordPress security plugins?

The best WordPress security plugins for most sites are Wordfence, Sucuri Security, Solid Security, MalCare, All-In-One Security (AIOS), Patchstack, a

Dive Deeper
What is the difference between posts and pages in WordPress?

What is the difference between posts and pages in WordPress?

The main difference between posts and pages in WordPress is that posts are timely, dated entries that appear in your blog feed, archives, and RSS fee

Dive Deeper
How much does it cost to build a WordPress website?

How much does it cost to build a WordPress website?

Building a WordPress website can cost anywhere from roughly the price of a domain and a year of budget hosting, if you do it yourself with free tools

Dive Deeper