WordPress powers over 40% of the web, and much of its flexibility comes from plugins. Plugins are self-contained bundles of PHP, JavaScript, and other assets that extend what WordPress can do—powering everything from simple tweaks to complex business features. If you’re a developer new to WordPress, learning how to build plugins is the gateway to customizing and scaling the platform for any need.
In this guide, you’ll learn the essentials of plugin development, set up a local environment using WordPress Studio, and build a fully functional example plugin. By the end, you’ll understand the anatomy of a plugin, how hooks work, and best practices for a maintainable and secure code.
Before you write a single line of code, you need a local WordPress environment. WordPress Studio is the fastest way to get started. Studio is open source, maintained by Automattic, and designed for seamless WordPress development.
Follow these steps:
Visit developer.wordpress.com/studio and download the installer for macOS or Windows.
To create a local site, launch Studio and click Add Site. You’ll see a simple window where you can name your new site. After entering a name and clicking Add Site, Studio automatically configures a complete WordPress environment for you—no command line knowledge needed. Once complete, your new site appears in Studio’s sidebar, providing convenient links to view it in your browser or access the WordPress admin dashboard.
Click the “Open site” link to open your site in the browser. You can also click the “WP Admin” button in Studio to access your site’s dashboard at /wp-admin. You’ll be automatically logged in as an Administrator. This is where you’ll manage plugins, test functionality, and configure settings.
Studio provides convenient “Open in…” buttons that detect your installed code editor (like Visual Code or Cursor) and let you open your project in your preferred editor. You can configure your default code editor in Studio’s settings. Once opened in your code editor, you’ll have complete access to browse, edit, and debug the WordPress installation files.
Once you have your local environment for WordPress development set up and running, locate the plugins folder . In your project root, navigate to:
wp-content/
└── plugins/
This is where all plugins live. To build your own, create a new folder (e.g., quick-reading-time) and add your plugin files there. Studio’s server instantly reflects changes when you reload your local site.
Every plugin starts as a folder with at least one PHP file. Let’s build a minimal “Hello World” plugin to demystify the process.
wp-content/plugins/
, create a folder called quick-reading-time
.quick-reading-time.php
.Your file structure should look like this:
wp-content/
└── plugins/
└── quick-reading-time/
└── quick-reading-time.php
Add the following code to quick-reading-time.php
:
<?php
/*
Plugin Name: Quick Reading Time
Description: Displays an estimated reading-time badge beneath post titles.
Version: 1.0
Author: Your Name
License: GPL-2.0+
Text Domain: quick-reading-time
*/
This header is a PHP comment, but WordPress scans it to list your plugin in Plugins → Installed Plugins. Activate it—nothing happens yet (that’s good; nothing is broken).
Tip: Each header field has a purpose. For example, Text Domain enables translation, and License is required for distribution in the Plugin Directory. Learn more in the Plugin Developer Handbook.
WordPress plugins interact with core events using hooks. There are two types:
Let’s add a reading-time badge using the the_content
filter:
function qrt_add_reading_time( $content ) {
// Only on single posts in the main loop
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
// 1. Strip HTML/shortcodes, count words
$plain = wp_strip_all_tags( strip_shortcodes( get_post()->post_content ) );
$words = str_word_count( $plain );
// 2. Estimate: 200 words per minute
$minutes = max( 1, ceil( $words / 200 ) );
// 3. Build the badge
$badge = sprintf(
'<p class="qrt-badge" aria-label="%s"><span>%s</span></p>',
esc_attr__( 'Estimated reading time', 'quick-reading-time' ),
/* translators: %s = minutes */
esc_html( sprintf( _n( '%s min read', '%s mins read', $minutes, 'quick-reading-time' ), $minutes ) )
);
return $badge . $content;
}
add_filter( 'the_content', 'qrt_add_reading_time' );
This snippet adds a reading time badge to post content using the the_content
filter. It checks context with is_singular()
, in_the_loop()
, and is_main_query()
to ensure the badge only appears on single posts in the main loop.
The code strips HTML and shortcodes using wp_strip_all_tags()
and strip_shortcodes()
, counts words, and estimates reading time. Output is localized with esc_attr__()
and _n()
. The function is registered with add_filter()
.
With this plugin activated, each post will now also display the reading time:
To style your badge, enqueue a stylesheet using the wp_enqueue_scripts
action:
function qrt_enqueue_assets() {
wp_enqueue_style(
'qrt-style',
plugin_dir_url( __FILE__ ) . 'style.css',
array(),
'1.0'
);
}
add_action( 'wp_enqueue_scripts', 'qrt_enqueue_assets' );
Create a style.css
file in the same folder:
.qrt-badge span {
margin: 0 0 1rem;
padding: 0.25rem 0.5rem;
display: inline-block;
background: #f5f5f5;
color: #555;
font-size: 0.85em;
border-radius: 4px;
}
Best practice: Only load assets when needed (e.g., on the front end or specific post types) for better performance.
With this change, the reading time info on each post should look like this:
To make the average reading speed configurable, let’s add a settings page and connect it to our plugin logic. We’ll store the user’s preferred words-per-minute (WPM) value in the WordPress options table and use it in our reading time calculation.
Add this code to your plugin file to register a new option and settings field:
// Register the setting during admin_init.
function qrt_register_settings() {
register_setting( 'qrt_settings_group', 'qrt_wpm', array(
'type' => 'integer',
'sanitize_callback' => 'qrt_sanitize_wpm',
'default' => 200,
) );
}
add_action( 'admin_init', 'qrt_register_settings' );
// Sanitize the WPM value.
function qrt_sanitize_wpm( $value ) {
$value = absint( $value );
return ( $value > 0 ) ? $value : 200;
}
This code registers a plugin option (qrt_wpm) for words-per-minute, using register_setting()
on the admin_init
hook. The value is sanitized with a custom callback using absint()
to ensure it’s a positive integer.
Add a new page under Settings in the WordPress admin:
function qrt_register_settings_page() {
add_options_page(
'Quick Reading Time',
'Quick Reading Time',
'manage_options',
'qrt-settings',
'qrt_render_settings_page'
);
}
add_action( 'admin_menu', 'qrt_register_settings_page' );
This code adds a settings page for your plugin under the WordPress admin “Settings” menu. It uses add_options_page()
to register the page, and hooks the function to admin_menu
so it appears in the dashboard. The callback (qrt_render_settings_page
) will output the page’s content.
Display a form for the WPM value and save it using the Settings API:
function qrt_render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'Quick Reading Time Settings', 'quick-reading-time' ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( 'qrt_settings_group' );
do_settings_sections( 'qrt_settings_group' );
$wpm = get_option( 'qrt_wpm', 200 );
?>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="qrt_wpm"><?php esc_html_e( 'Words Per Minute', 'quick-reading-time' ); ?></label>
</th>
<td>
<input name="qrt_wpm" type="number" id="qrt_wpm" value="<?php echo esc_attr( $wpm ); ?>" class="small-text" min="1" />
<p class="description"><?php esc_html_e( 'Average reading speed for your audience.', 'quick-reading-time' ); ?></p>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
</div>
<?php
}
This function renders the plugin’s settings page, displaying a form to update the WPM value. It checks user permissions with current_user_can()
, outputs the form using settings_fields()
, do_settings_sections()
, and retrieves the saved value with get_option()
. The form submits to the WordPress options system for secure saving.
Update your reading time calculation to use the saved WPM value:
function qrt_add_reading_time( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
$plain = wp_strip_all_tags( strip_shortcodes( get_post()->post_content ) );
$words = str_word_count( $plain );
$wpm = (int) get_option( 'qrt_wpm', 200 );
$minutes = max( 1, ceil( $words / $wpm ) );
$badge = sprintf(
'<p class="qrt-badge" aria-label="%s"><span>%s</span></p>',
esc_attr__( 'Estimated reading time', 'quick-reading-time' ),
esc_html( sprintf( _n( '%s min read', '%s mins read', $minutes, 'quick-reading-time' ), $minutes ) )
);
return $badge . $content;
}
This function adds a reading time badge to post content. It checks context with is_singular()
, in_the_loop()
, and is_main_query()
to ensure it runs only on single posts in the main loop. It strips HTML and shortcodes using wp_strip_all_tags()
and strip_shortcodes()
), counts words, and retrieves the WPM value with get_option()
. The badge is output with proper escaping and localization using esc_attr__()
, esc_html()
, and _n()
).
With these changes, your plugin now provides a user-friendly settings page under Settings → Quick Reading Time. Site administrators can set the average reading speed for their audience, and your plugin will use this value to calculate and display the estimated reading time for each post.
Before we wrap up with best practices, let’s review the complete code for the “Quick Reading Time” plugin you built in this guide. This section brings together all the concepts covered—plugin headers, hooks, asset loading, and settings—into a single, cohesive example. Reviewing the full code helps solidify your understanding and provides a reference for your own projects.
At this stage, you should have a folder named quick-reading-time
inside your wp-content/plugins/
directory, and a file called quick-reading-time.php
with the following content:
<?php
/*
Plugin Name: Quick Reading Time
Description: Displays an estimated reading-time badge beneath post titles.
Version: 1.0
Author: Your Name
License: GPL-2.0+
Text Domain: quick-reading-time
*/
// Register the WPM setting during admin_init.
function qrt_register_settings() {
register_setting( 'qrt_settings_group', 'qrt_wpm', array(
'type' => 'integer',
'sanitize_callback' => 'qrt_sanitize_wpm',
'default' => 200,
) );
}
add_action( 'admin_init', 'qrt_register_settings' );
// Sanitize the WPM value.
function qrt_sanitize_wpm( $value ) {
$value = absint( $value );
return ( $value > 0 ) ? $value : 200;
}
// Add a settings page under Settings.
function qrt_register_settings_page() {
add_options_page(
'Quick Reading Time',
'Quick Reading Time',
'manage_options',
'qrt-settings',
'qrt_render_settings_page'
);
}
add_action( 'admin_menu', 'qrt_register_settings_page' );
// Render the settings page.
function qrt_render_settings_page() {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<div class="wrap">
<h1><?php esc_html_e( 'Quick Reading Time Settings', 'quick-reading-time' ); ?></h1>
<form method="post" action="options.php">
<?php
settings_fields( 'qrt_settings_group' );
do_settings_sections( 'qrt_settings_group' );
$wpm = get_option( 'qrt_wpm', 200 );
?>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="qrt_wpm"><?php esc_html_e( 'Words Per Minute', 'quick-reading-time' ); ?></label>
</th>
<td>
<input name="qrt_wpm" type="number" id="qrt_wpm" value="<?php echo esc_attr( $wpm ); ?>" class="small-text" min="1" />
<p class="description"><?php esc_html_e( 'Average reading speed for your audience.', 'quick-reading-time' ); ?></p>
</td>
</tr>
</table>
<?php submit_button(); ?>
</form>
</div>
<?php
}
// Add the reading time badge to post content.
function qrt_add_reading_time( $content ) {
if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {
return $content;
}
$plain = wp_strip_all_tags( strip_shortcodes( get_post()->post_content ) );
$words = str_word_count( $plain );
$wpm = (int) get_option( 'qrt_wpm', 200 );
$minutes = max( 1, ceil( $words / $wpm ) );
$badge = sprintf(
'<p class="qrt-badge" aria-label="%s"><span>%s</span></p>',
esc_attr__( 'Estimated reading time', 'quick-reading-time' ),
esc_html( sprintf( _n( '%s min read', '%s mins read', $minutes, 'quick-reading-time' ), $minutes ) )
);
return $badge . $content;
}
add_filter( 'the_content', 'qrt_add_reading_time' );
// Enqueue the plugin stylesheet.
function qrt_enqueue_assets() {
wp_enqueue_style(
'qrt-style',
plugin_dir_url( __FILE__ ) . 'style.css',
array(),
'1.0'
);
}
add_action( 'wp_enqueue_scripts', 'qrt_enqueue_assets' );
You should also have a style.css
file in the same folder with the following content to style the badge:
.qrt-badge span {
margin: 0 0 1rem;
padding: 0.25rem 0.5rem;
display: inline-block;
background: #f5f5f5;
color: #555;
font-size: 0.85em;
border-radius: 4px;
}
This plugin demonstrates several foundational concepts in WordPress development:
admin_init
, admin_menu
, wp_enqueue_scripts
) and a filter (the_content
) to integrate with WordPress at the right moments.By bringing these elements together, you have a robust, maintainable, and extensible plugin foundation. Use this as a template for your own ideas, and continue exploring the WordPress Plugin Developer Handbook for deeper knowledge.
Building a WordPress plugin is more than just making something work—it’s about creating code that is robust, secure, and maintainable for years to come. As your plugin grows or is shared with others, following best practices becomes essential to avoid pitfalls that can lead to bugs, security vulnerabilities, or compatibility issues. The habits you form early in your development journey will shape the quality and reputation of your work.
Let’s explore the foundational principles that set apart professional WordPress plugin development.
esc_html()
, esc_attr()
, and sanitize_text_field()
to keep your plugin safe.__()
, and _n()
for localization. Internationalization (i18n) ensures your plugin is accessible to users worldwide. Wrap all user-facing text in translation functions and provide a text domain.wp scaffold plugin
, wp i18n make-pot
). Version control is your safety net, allowing you to track changes, collaborate, and roll back mistakes. WP-CLI tools can automate repetitive tasks and enforce consistency.WP_DEBUG
and use tools like Query Monitor for troubleshooting. Proactive debugging surfaces issues early, making them easier to fix and improving your plugin’s reliability.Tip: Adopt these habits early—retrofitting best practices later is much harder. By making them part of your workflow from the start, you’ll save time, reduce stress, and build plugins you can be proud of.
You now have a working plugin that demonstrates the three “golden” hooks:
the_content
– injects the badge.wp_enqueue_scripts
– loads the stylesheet.admin_menu
– (optionally) adds a settings page.Where you go next is up to you—try adding custom post types (init
), REST API endpoints (rest_api_init
), scheduled events, or Gutenberg blocks (register_block_type
). The mental model is the same: find the hook, write a callback, let WordPress run it.
Every plugin—whether 40 KB or 40 MB—starts with a folder, a header, and a hook. Master that foundation, and the rest of the WordPress ecosystem opens wide. Experiment locally, keep your code readable and secure, and iterate in small steps. With practice, the leap from “I wish WordPress could…” to “WordPress does” becomes second nature.
Ready to build your own plugin? Try the steps above, share your results in the comments, or explore more advanced topics in our developer blog. Happy coding!
Original Post https://wordpress.com/blog/2025/07/31/introduction-to-wordpress-plugin-development/