Jumping from using plugins to building them is a significant step. It’s the difference between driving a car and opening the hood to engineer the engine. This guide will walk you, as a new developer, through the technical fundamentals of creating a secure, well-structured, and functional WordPress plugin.
We’ll move beyond a simple “Hello World” and build a practical plugin that:
- Creates a custom shortcode:
[cta-button] - Allows customization with attributes (e.g.,
[cta-button text="Click Me!" url="https://example.com"]) - Safely enqueues its own stylesheet for a professional look.
- Follows security and coding best practices from day one.
1. 🏗️ The Foundation: Plugin Structure & Header
First, you need to tell WordPress your plugin exists.
- Navigate to your WordPress installation’s
/wp-content/plugins/directory. - Create a new folder for your plugin. Give it a unique, descriptive name (e.g.,
my-cta-plugin). - Inside that folder, create your main plugin file. This must be a PHP file and should ideally have the same name (e.g.,
my-cta-plugin.php).
Now, open my-cta-plugin.php and add the following code block at the very top. This is the plugin header, and it’s the only part that is absolutely required for WordPress to recognize your file as a plugin.
<?php
/**
* Plugin Name: My First CTA Plugin
* Plugin URI: https://example.com/my-cta-plugin
* Description: Adds a simple, customizable call-to-action button via a shortcode.
* Version: 1.0.0
* Requires at least: 5.2
* Requires PHP: 7.2
* Author: Your Name
* Author URI: https://your-website.com
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: my-cta-plugin
* Domain Path: /languages
*/
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
- Plugin Name: This is what users see in the plugin list.
- Description: Explains what the plugin does.
- Version: Crucial for managing updates and cache-busting scripts.
- Author/URI: Gives you credit.
- License: Declares the plugin’s license, which must be GPL-compatible to be hosted on WordPress.org.
- Text Domain: This is essential for making your plugin translatable (localization).
if ( ! defined( 'WPINC' ) )…: This is a basic security measure. It prevents users from accessing your PHP file directly, which could expose sensitive information or paths.
Save this file, go to your WordPress Admin > Plugins, and you will see “My First CTA Plugin” in the list. You can activate it! Of course, it doesn’t do anything… yet.
2. ⚙️ The Core Concept: WordPress Hooks (Actions vs. Filters)
You cannot just write a PHP function and expect it to run. WordPress is an event-driven application. It runs its core code and, at specific points, “pauses” to check if any plugins or themes want to add or change something. These “pauses” are called hooks.
There are two types of hooks:
- Actions (
add_action): Let you DO something at a specific point. You’re adding a new function to an event.- Analogy: When the “post is published” event fires (
publish_post), you can hook in an action to send a tweet. You’re adding a new capability.
- Analogy: When the “post is published” event fires (
- Filters (
add_filter): Let you CHANGE something that already exists. You’re modifying data before it’s used.- Analogy: When WordPress fetches the post’s content (
the_content), you can hook in a filter to add “Read more…” at the end. You’re modifying the existing content.
- Analogy: When WordPress fetches the post’s content (
You will use both to build your plugin.
3. 🛠️ Building the Plugin: The Shortcode
A shortcode is the perfect first-plugin feature. It lets a user add complex functionality (like our button) right inside the post editor.
Add the following code to your my-cta-plugin.php file (after the header).
/**
* Shortcode handler function.
*
* @param array $atts Shortcode attributes.
* @return string HTML output for the button.
*/
function mfp_cta_button_shortcode( $atts ) {
// 1. Set default values for attributes
$atts = shortcode_atts(
array(
'text' => 'Click Here',
'url' => '#',
),
$atts,
'cta-button'
);
// 2. Sanitize and escape attributes for security
// We sanitize the text for general safety and escape the URL for use in an href attribute.
$button_text = esc_html( $atts['text'] );
$button_url = esc_url( $atts['url'] );
// 3. Generate and return the HTML
// Note: We are using esc_html and esc_url *again* right at the point of output.
// This is a "defense in depth" best practice.
return '<a href="' . esc_url( $button_url ) . '" class="mfp-cta-button">' . esc_html( $button_text ) . '</a>';
}
/**
* Register the shortcode with WordPress.
*/
function mfp_register_shortcodes() {
add_shortcode( 'cta-button', 'mfp_cta_button_shortcode' );
}
// We hook into 'init', which runs after WordPress has finished loading.
add_action( 'init', 'mfp_register_shortcodes' );
Let’s break this down:
mfp_cta_button_shortcode($atts): This is our “handler” function. WordPress will call this function whenever it finds[cta-button]. The$attsvariable will be an associative array of any attributes the user added (e.g.,['text' => 'Click Me!', 'url' => '...']).shortcode_atts(): This is a WordPress helper function that merges the user’s attributes with your defaults. If the user writes[cta-button], the text will be “Click Here” and the URL will be “#”.add_shortcode(): This function tells WordPress, “When you see the tag[cta-button], run the functionmfp_cta_button_shortcodeand replace the tag with whatever that function returns.”add_action('init', ...): We register our shortcode using an action hook. We don’t want this code to run randomly; we want it to run at a specific, safe time.initis the standard hook for registering things like shortcodes and post types.
Go test it! Save the file, make sure the plugin is active, and add [cta-button text="My New Button" url="https://google.com"] to any post. You’ll see a link!
4. 🎨 Making it Look Good: Enqueuing Stylesheets
Our button is just a plain link. Let’s style it. Never put inline <style> blocks or link to stylesheets in your HTML. The only correct way to add scripts and styles in WordPress is by “enqueuing” them.
- Inside your
my-cta-pluginfolder, create a new folder namedcss. - Inside
css, create a file namedstyle.css. - Add this CSS to
style.css:CSS.mfp-cta-button { display: inline-block; padding: 12px 24px; background-color: #0073aa; color: #ffffff; text-decoration: none; font-weight: bold; border-radius: 4px; transition: background-color 0.3s ease; } .mfp-cta-button:hover { background-color: #005177; color: #ffffff; }
Now, add this PHP code to your my-cta-plugin.php file:
/**
* Enqueue the plugin's stylesheet.
*/
function mfp_enqueue_styles() {
wp_enqueue_style(
'mfp-cta-style', // A unique 'handle' for this stylesheet
plugin_dir_url( __FILE__ ) . 'css/style.css', // The path to the file
array(), // Dependencies (e.g., if you needed another stylesheet to load first)
'1.0.0' // The plugin version number
);
}
// Use the 'wp_enqueue_scripts' action hook to load frontend assets.
add_action( 'wp_enqueue_scripts', 'mfp_enqueue_styles' );
This is critical:
add_action('wp_enqueue_scripts', ...): This is the correct action hook for adding frontend scripts and styles. Do not usewp_headorwp_footer.wp_enqueue_style(): The WordPress function that properly adds the stylesheet to the page’s<head>.plugin_dir_url( __FILE__ ): A crucial function. It gets the full URL to your plugin’s directory (e.g.,https://example.com/wp-content/plugins/my-cta-plugin/). This ensures your path is always correct, even if the user renames theirwp-contentfolder.'1.0.0': By passing your plugin’s version, you enable “cache-busting.” When you update your plugin to1.0.1and change this number, WordPress will automatically force users’ browsers to download the new stylesheet.
Refresh your post. Your button should now be blue and beautiful!
5. 🛡️ Best Practices for New Developers
What separates a hobby plugin from a professional one? Best practices.
✅ 1. Security: Sanitize Input, Escape Output
This is the most important rule in WordPress development.
- Sanitize Input: Never trust user data. “Input” is any data coming into your plugin (from
$_POST,$_GET, options, or shortcode attributes). You must clean it before you save it or use it.sanitize_text_field( $string ): For plain text. Strips tags and newlines.sanitize_email( $email ): For email addresses.absint( $number ): For ensuring you have an absolute integer.
- Escape Output: Never trust data you display. “Output” is any data you are printing to the screen (HTML, attributes, etc.). You must “escape” it to prevent Cross-Site Scripting (XSS) attacks.
esc_html( $string ): For outputting inside an HTML element (like we did for our button text:>...</a>).esc_url( $url ): For outputting inhreforsrcattributes.esc_attr( $string ): For outputting in any other HTML attribute (e.g.,title="...").
Rule of Thumb: Data coming in gets sanitized. Data going out gets escaped.
✅ 2. Naming: Prefix Everything
WordPress has a global namespace. This means your function register_shortcodes() could conflict with another plugin’s register_shortcodes() function, crashing the site.
You must prefix all your functions, classes, and global variables with a unique string. I used mfp_ (for My First Plugin). A good prefix is 3-4 letters and related to your plugin’s name.
- Bad:
register_shortcodes() - Good:
mfp_register_shortcodes() - Best (OOP):
class MyFirstPlugin { public function register_shortcodes() { ... } }(This is the next step in your journey, but for now, prefixing is essential).
✅ 3. Plugin Lifecycle: Activation & Deactivation
What happens when your plugin is activated, deactivated, or uninstalled? You need to plan for this.
- Activation (
register_activation_hook): Use this to run setup tasks once. A common use is flushing rewrite rules if you’ve added a new custom post type. - Deactivation (
register_deactivation_hook): Use this to temporarily clean up. Do not delete user data here. The user might just be debugging and plans to re-activate. - Uninstallation (
register_uninstall_hook): This runs only when the user clicks “Delete”. This is where you clean up your plugin’s data, like options from thewp_optionstable.
Here’s how you would register these hooks in your main plugin file:
/**
* Runs only when the plugin is activated.
*/
function mfp_activate_plugin() {
// Example: Add a default option to the database.
add_option( 'mfp_plugin_version', '1.0.0' );
}
register_activation_hook( __FILE__, 'mfp_activate_plugin' );
/**
* Runs only when the plugin is deactivated.
*/
function mfp_deactivate_plugin() {
// We don't delete our option, just in case they reactivate.
}
register_deactivation_hook( __FILE__, 'mfp_deactivate_plugin' );
/**
* Runs only when the plugin is uninstalled (deleted).
* This is defined in the WordPress Developer Handbook as the
* preferred way to register an uninstall hook.
*/
function mfp_uninstall_plugin() {
// Now we clean up our data.
delete_option( 'mfp_plugin_version' );
}
register_uninstall_hook( __FILE__, 'mfp_uninstall_plugin' );
Your Final my-cta-plugin.php File
Here is the complete code for your first plugin, all in one file.
<?php
/**
* Plugin Name: My First CTA Plugin
* Plugin URI: https://example.com/my-cta-plugin
* Description: Adds a simple, customizable call-to-action button via a shortcode.
* Version: 1.0.0
* Requires at least: 5.2
* Requires PHP: 7.2
* Author: Your Name
* Author URI: https://your-website.com
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: my-cta-plugin
* Domain Path: /languages
*/
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
// === SHORTCODE ===
/**
* Shortcode handler function.
*
* @param array $atts Shortcode attributes.
* @return string HTML output for the button.
*/
function mfp_cta_button_shortcode( $atts ) {
// 1. Set default values for attributes
$atts = shortcode_atts(
array(
'text' => 'Click Here',
'url' => '#',
),
$atts,
'cta-button'
);
// 2. Sanitize and escape attributes.
$button_text = esc_html( $atts['text'] );
$button_url = esc_url( $atts['url'] );
// 3. Generate and return the HTML, escaping on output.
return '<a href="' . esc_url( $button_url ) . '" class="mfp-cta-button">' . esc_html( $button_text ) . '</a>';
}
/**
* Register the shortcode with WordPress.
*/
function mfp_register_shortcodes() {
add_shortcode( 'cta-button', 'mfp_cta_button_shortcode' );
}
add_action( 'init', 'mfp_register_shortcodes' );
// === STYLESHEET ===
/**
* Enqueue the plugin's stylesheet.
*/
function mfp_enqueue_styles() {
wp_enqueue_style(
'mfp-cta-style', // Handle
plugin_dir_url( __FILE__ ) . 'css/style.css', // Path
array(), // Dependencies
'1.0.0' // Version
);
}
add_action( 'wp_enqueue_scripts', 'mfp_enqueue_styles' );
// === PLUGIN LIFECYCLE HOOKS ===
/**
* Runs only when the plugin is activated.
*/
function mfp_activate_plugin() {
// Add a default option to the database.
add_option( 'mfp_plugin_version', '1.0.0' );
}
register_activation_hook( __FILE__, 'mfp_activate_plugin' );
/**
* Runs only when the plugin is deactivated.
*/
function mfp_deactivate_plugin() {
// Do nothing on deactivation.
}
register_deactivation_hook( __FILE__, 'mfp_deactivate_plugin' );
/**
* Runs only when the plugin is uninstalled (deleted).
*/
function mfp_uninstall_plugin() {
// Clean up our option.
delete_option( 'mfp_plugin_version' );
}
register_uninstall_hook( __FILE__, 'mfp_uninstall_plugin' );
Congratulations! You’ve just built a secure, functional, and well-structured WordPress plugin that follows modern development standards.