WordPress Plugin Development Tutorial: From Zero to Hero

This is the first part of my WordPress Plugin Development tutorial. Check also the second part.

0. My dev environment:

  • OS: Linux (Ubuntu)
  • Editor: Atom editor + PHP debugger + linters (PHPCS and PHPMD)
  • Web-server: built-in PHP web-server (php -S localhost:3000)

I use Linux and Atom editor with PHP debugger and PHPCS enabled. But any code editor work.

For coding styling, I recommend to use the WordPress coding standards, but PSR is also acceptable. Try to not use your own code styling, especially if other people are going to work on your code.

For Windows, I recommend “Local by Flywheel”.

1. WP-CLI:

In this tutorial, we are going to use WP-CLI, the official command-line tool for interacting with and managing your WordPress sites.

For install instructions visit: https://wp-cli.org/

2. Have a WordPress instance for the plugin:

We need a running copy of WordPress. This will be a shell for our plugin.

Either install a brand new WordPress copy (recommended) or work on an existing project.

You can quickly download/extract WordPress with WP-CLI:

mkdir my-wp
cd my-wp
wp core download
(out)Downloading WordPress 5.2.3 (en_US)...
(out)md5 hash verified: bde83b629bc7a833f7000bc522cde120
(out)index.php        wp-blog-header.php    wp-includes        wp-settings.php
(out)license.txt      wp-comments-post.php  wp-links-opml.php  wp-signup.php
(out)readme.html      wp-config-sample.php  wp-load.php        wp-trackback.php
(out)wp-activate.php  wp-content            wp-login.php       xmlrpc.php
(out)wp-admin         wp-cron.php           wp-mail.php

3. Our first plugin:

Let’s create our my-plugin plugin with wp-cli. In the WordPress root folder, run:

wp scaffold plugin my-plugin
(out)Success: Created plugin files.
(out)Success: Created test files.

This command creates a my-plugin folder inside the wp-content/plugins folder:

tree wp-content/plugins/my-plugin
(out)├── bin
(out)│   └── install-wp-tests.sh
(out)├── Gruntfile.js
(out)├── my-plugin.php
(out)├── package.json
(out)├── phpunit.xml.dist
(out)├── readme.txt
(out)└── tests
(out)    ├── bootstrap.php
(out)    └── test-sample.php
(out)2 directories, 8 files

Go to Plugins and activate the plugin:

Now, open the my-plugin/my-plugin.php file. This is the entry point of our plugin:

And replace the // Your code ... with echo 'Hello, World!':

Now, open the home page:

Notice the "Hello, World!" on the top left. Open the source code and notice that it is the very first string of the code.

Congratulations! You have created a WordPress plugin!!!

4. Enable debug mode:

Set define('WP_DEBUG', true) on your wp-config.php (the constant is already defined, just change the value to true):

 * For developers: WordPress debugging mode.
 * Change this to true to enable the display of notices during development.
 * It is strongly recommended that plugin and theme developers use WP_DEBUG
 * in their development environments.
 * For information on other constants that can be used for debugging,
 * visit the Codex.
 * @link https://codex.wordpress.org/Debugging_in_WordPress
define( 'WP_DEBUG', true );

/* That's all, stop editing! Happy publishing. */

/** Absolute path to the WordPress directory. */
if ( ! defined( 'ABSPATH' ) ) {
        define( 'ABSPATH', dirname( __FILE__ ) . '/' );

/** Sets up WordPress vars and included files. */
require_once( ABSPATH . 'wp-settings.php' );

The log is going to be saved to wp-content/debug.log

5. Change the headers:

Adapt the headers with your own information: plugin name, URL, version, etc.

Also, delete the "echo" command.

Save and reload the plugins page:

Text Domain and Domain Path are optional and used for internationalization (translation). If omitted, "Text Domain" is the plugin slug (ex. "my-plugin"). "Domain Path" is only necessary if your plugin is not in the WP official plugin repository. If not published there, leave the "/languages" set.

6. Security: Prevent direct access to your file

Prevent direct access to your file with defined( 'ABSPATH' ) || exit;

7. The plugin class:

1. Next step is to:

  • Create a class ("MyPlugin");
  • Put initialization code in __construct();
  • Create an instance object;
  • Save the instance as a global variable (optional), so it can be accessed anywhere;
  • Do not create if the class already exists;

2. The resulting code:

8. (OPTIONAL) Good Practice: Do not use globals:

Saving the instance as a global object is not the best solution.

A better approach is to define a global my_plugin() function that returns the current instance. This function is just an alias to the "instance()" method, so we don't need to call "MyPlugin::instance()".

9. (OPTIONAL) Good Practice: Keep class definitions in separated files:

According to the PSR-1 coding standard:

"A file should declare new symbols (classes, functions, constants, etc.) and cause no other side effects, or it should execute logic with side effects, but should not do both."

This means: You shouldn't have class definitions and commands in the same file.



Notice the "class-" filename prefix. According to the WordPress coding standard, a class filename should start with the "class-" prefix.

[OPTIONAL] Autoloader (PSR-4):

To improve even more your code, you should replace the code that "check if the class exists/loads class file/create instance" with the standard PHP autoloader:

PHP won't load the class until it reaches MyPlugin::instance(). At this point, it will check if the "MyPlugin" class exists. As it doesn't exist, it will call my_plugin_autoload( 'MyPlugin' ), loading the class and proceeding with the execution.

The use of an autoloader saves CPU and memory resources, as it doesn't load uncesessary classes.

With this solution, just save your new classes in a class-<my-class-name-lowercased>.php file and call the class as if the file was already loaded.

For a "A_NewClass" class, use "class-a_newclass.php" as the filename. You don't need to touch the above code, nor to call include/require to load the new class. Just create the file and whenever you call the class, PHP will autoload it.

Don't forget to rename the "my_plugin_autoload()" with something unique to your plugin - I recommend using your plugin name as the prefix. Also, you may want to change the function logic if you want a different file name convention.

10. Hooks: filters and actions

WordPress has a beautiful architecture. It is based on an event-driven design pattern. It is more like Javascript/React than Laravel/Ruby on Rails.

In WordPress, events are called "hooks". There are 2 hook types: actions and filters.

Actions are like any regular JS event. For example, the "wp_footer" event is triggered whenever the footer is rendered. You can use it to, for example, update a page counter.

Filters are "get" functions that plugins can intercept and make changes to the result. For example, if you need to get the page title, you should call the "wp_title" filter instead of loading it from the database. By doing this, you will allow plugins to make changes to the result, which is good for the extensibility sake.

Now, let's put our hands on actions and filters.

10.1. Actions:

In WordPress, whenever the footer is rendered, an event called wp_footer happens. We can assign our own callback to this event, just like we do with the JS ¨onclick" event.

Put the following code in our constructor:

Notice the add_action( $action, $callback ). That's how we assign a callback to an action.

Now, open the homepage and check the footer:

When the footer is rendered, it calls a do_action('wp_footer'), which triggers all methods assigned to the "wp_footer" action.

The do_action( $action ) is how we create a new action.

do_action() vs add_action():

  • do_action(): Producer side. I.e. used by the side that defines a new action. Used to create new actions. Example: WordPress core;
  • add_action(): Consumer side. I.e. used by the side that consumes the action. Used to assign callbacks to actions. Example: plugin;

Using methods instead of functions:

Notice that we have defined our my_fnc action as a regular function. That's not a good practice when working with classes. Let's refactor it as a method:

When using a method on add_action(), pass the instance object ($this) and the method name in an array.

Static methods:

We can also call static methods. Just pass the class name instead of $this. Example:

class MyPlugin {
  public function __construct() {
    add_action( 'wp_footer', [ 'MyPlugin', 'my_fnc' ] );

  public static function my_fnc() {
    echo 'Hello World';

This is specially useful when calling methods from a different class.

Localization (multiple idioms, i18n):

To support multiple idioms, instead of echo 'Hello World', we should use the _e()  or echo __() function.

_e() is a synonym for echo __().

Text Domain:

The second parameter is your plugin namespace, aka text-domain. By default, the text domain is your plugin slug (your folder name, ex. my-plugin), but you can define a different text-domain on the header.

Variables, plurals and others:

For advanced usage, check the official documentation:

Even if you are not going to translate your plugin to multiple languages, it is a BEST PRACTICE to use __() and _e() for human-readable outputs.


Methods are called in the same order they have been added. Example:

This will show a "World Hello" (because fnc2 is being called before fnc1).

We can also manually specify the order with a 3rd argument - the priority. Lower numbers are executed earlier.

add_action( 'wp_footer', 'my_fnc2', 20 );
add_action( 'wp_footer', 'my_fnc1', 10 );

This will show a "Hello World".

If not specified, the default priority is 10.

Passing arguments:

Some actions require extra arguments. For example, let's say we want to do some cleanup when a post is deleted. To implement this, we should listen to the wp_trash_post action, which passes a $post_id argument:

add_action( 'wp_trash_post', my_trash_fnc, 10, 1 );
function my_trash_fnc( $post_id ) {
  // my cleanup code

The last argument 1, is the function argument count.

On WP core, this is executed whenever a post is deleted:

do_action( 'wp_trash_post', $post_id );

Multiple arguments:

If there more than one argument, you should pass them all in an array on do_action():

To consume this action, just define the arguments as usual and set the argument count in the "add_action()".

add_action( 'some_action', my_fnc, 10, 4 );
function my_fnc( $arg1, $arg2, $arg3, $arg4 ) {
  // code

Other core actions:

WordPress has many other actions that we can use to extend it.

The complete list:

Besides those, each plugin/theme can have its own actions which you can use to extend them. For example, Woocommerce has many of them:

Custom actions:

It is a good practice to add your own hooks to your code, so other developers can extend it.

You define a new action by calling a do_action('your_unique_name'). Then, document it, so other plugins will be able to extend your plugin.

10.2. Filters:

Filters are about getting variables. For example, there is a core filter named wp_title that gets the title of blog.

Let's say we want to replace the all the os in the blog's title with es. Just do this:

If the blog title is "Hello World", it will become "Helle Werld".

Your filter function should ALWAYS have a return

Defining a new filter:

The filter equivalent of do_action() is apply_filters().

Let's see how WP's core gets the title:

The second argument $title is the starting value. It can be also used as a default value, if no methods are added to the filter.


The syntax is the same as in actions:

Then, call it with (no need to pass multiple arguments in an array):

11. Debug: Viewing hooks:

A handy tool for WordPress development are plugins that show the hooks on screen.

I recommend the "Simply Show Hooks" plugin:

11.1. Usage:

After installing and activate it, you must be logged in as WordPress admin to use it.

Open any page > admin toolbar (the black, topmost bar) > Simply Show Hooks > Show action hooks

This ends part 1 of this tutorial. On Part 2 we will see how to create a menu item and present a form on the admin side.

Close Menu