Your first WordPress plugin

Write a plugin header WordPress understands, hook into WordPress, and activate and test the plugin from the admin and WP-CLI.

  • Intermediate
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Create a working plugin
  • Activate and test it
  • Write a complete plugin header and know what each field does
  • Diagnose the common errors that stop a plugin activating

Before you start

A plugin is a PHP file with a special comment at the top. That is all WordPress needs to list it, activate it and load it on every request. This lesson builds the main file of the events plugin you will grow through the rest of the course, and shows how to prove it is running.

If you took the WordPress Development Complete Course, you already have a myplugin folder with an events post type. Keep it. This lesson rebuilds the main file properly, and later lessons move the rest of the code into a structure that scales.

How WordPress finds plugins

When you open the Plugins screen, WordPress scans wp-content/plugins. It looks at PHP files directly in that folder, and at PHP files one level down inside each subfolder. For each file, it reads the first 8 KB and looks for a comment containing Plugin Name:. Any file with that line is a plugin.

So both of these are valid:

Plain text
wp-content/plugins/hello.php
wp-content/plugins/myplugin/myplugin.php

Use the folder form for anything real. It gives the plugin room for more files, and the folder name becomes the plugin's slug. WordPress identifies a plugin by its folder and main file together, myplugin/myplugin.php, which is what it stores in the active_plugins option.

Name the main file after the folder. WordPress does not require it, but the WordPress.org directory, WP-CLI users and other developers expect it, and it makes the main file obvious.

The plugin header

Create wp-content/plugins/myplugin/myplugin.php:

PHP
<?php
/**
 * Plugin Name:       My Plugin
 * Plugin URI:        https://example.com/myplugin/
 * Description:       Events, bookings and booking tools for the course site.
 * Version:           0.1.0
 * Requires at least: 6.5
 * Requires PHP:      8.1
 * Author:            Your Name
 * Author URI:        https://example.com/
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       myplugin
 * Domain Path:       /languages
 */

defined( 'ABSPATH' ) || exit;

Only Plugin Name is required. The others matter as soon as anyone else installs the plugin:

FieldWhat WordPress does with it
Plugin NameThe name in the plugin list. Without it, the file is not a plugin
DescriptionThe text under the name on the Plugins screen
VersionShown in the list; your own code should also use it for cache busting and upgrades
Requires at leastWordPress refuses to activate the plugin on older versions
Requires PHPWordPress refuses to activate the plugin on older PHP versions
Author, Author URI, Plugin URILinks and credits in the plugin list
License, License URIThe licence; WordPress.org requires a GPL-compatible one
Text DomainThe translation domain; must match the slug for WordPress.org translations
Domain PathFolder for bundled translation files, relative to the plugin folder

Later lessons add Requires Plugins for dependencies and Update URI for plugins updated from outside WordPress.org.

The defined( 'ABSPATH' ) || exit; line stops the file doing anything if someone requests it directly by URL. ABSPATH is defined by WordPress during bootstrap, so it only exists when WordPress loaded the file.

Constants for the plugin

Add the constants every later lesson relies on, straight after the guard:

PHP
// wp-content/plugins/myplugin/myplugin.php
define( 'MYPLUGIN_VERSION', '0.1.0' );
define( 'MYPLUGIN_PATH', plugin_dir_path( __FILE__ ) );
define( 'MYPLUGIN_URL', plugin_dir_url( __FILE__ ) );
  • MYPLUGIN_VERSION matches the header. Keep them in step when you release.
  • MYPLUGIN_PATH is the server path to the plugin folder, with a trailing slash, for require_once.
  • MYPLUGIN_URL is the URL of the folder, for CSS, JavaScript and images.

Never hard-code /wp-content/plugins/myplugin/. Sites can move wp-content, and on some hosts the plugins folder lives somewhere else entirely.

Your first hook

A plugin that only defines constants does nothing visible. Add a small, useful hook: a link to the events list in the plugin's row on the Plugins screen. WordPress filters those links with a dynamic hook named after the plugin's basename:

PHP
// wp-content/plugins/myplugin/myplugin.php
add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'myplugin_action_links' );

/**
 * Add an "Events" link next to Deactivate on the Plugins screen.
 *
 * @param array $actions Existing action links.
 * @return array
 */
function myplugin_action_links( array $actions ): array {
    $url = admin_url( 'edit.php?post_type=myplugin_event' );

    $actions['myplugin_events'] = sprintf(
        '<a href="%s">%s</a>',
        esc_url( $url ),
        esc_html__( 'Events', 'myplugin' )
    );

    return $actions;
}

plugin_basename( __FILE__ ) returns myplugin/myplugin.php, so the hook is plugin_action_links_myplugin/myplugin.php. Because the name includes your file, the filter only ever sees your row.

If your events post type from the earlier course is registered in includes/post-types.php, load it too:

PHP
// wp-content/plugins/myplugin/myplugin.php
require_once MYPLUGIN_PATH . 'includes/post-types.php';

Activating the plugin

In the admin, go to Plugins and click Activate under My Plugin. The "Events" link appears in the row.

From the terminal, WP-CLI does the same and is quicker when you test repeatedly:

Shell
wp plugin activate myplugin
wp plugin list --status=active
wp plugin get myplugin --fields=name,status,version

WP-CLI uses the folder name as the plugin slug, so myplugin is enough.

Activation does two things. It adds the plugin to the active_plugins option, and it loads the main file once in a sandbox to check it does not cause a fatal error. From then on, WordPress includes myplugin.php on every request, early in the bootstrap, before the theme and before init fires.

Proving it runs

Look at the Plugins screen for the link. For anything less visible, check from the command line:

Shell
wp eval 'var_dump( defined( "MYPLUGIN_VERSION" ), function_exists( "myplugin_action_links" ) );'

Both values should be true. wp eval boots WordPress, including active plugins, then runs your PHP.

For a quick trace while developing, write to the debug log:

PHP
// Temporary: remove before committing.
add_action( 'init', static function () {
    error_log( 'myplugin loaded, version ' . MYPLUGIN_VERSION );
} );

With WP_DEBUG_LOG on, the line appears in wp-content/debug.log on every request.

When activation fails

Most first-plugin problems produce one of these messages:

MessageCauseFix
The plugin does not have a valid headerNo Plugin Name: line, or it is not in the first 8 KBPut the header comment at the very top of the main file
Plugin could not be activated because it triggered a fatal errorA PHP error while loading the file, such as a duplicate function nameRead the error shown, or debug.log, and fix it
The plugin generated N characters of unexpected output during activationSomething printed output: whitespace before <?php, a closing ?> followed by a newline, or an echoRemove stray output; omit the closing ?> tag in PHP-only files
This plugin requires a newer version of PHPRequires PHP is higher than the server's versionUpgrade PHP or lower the requirement honestly

A fatal error in an active plugin can take down the whole site. Current versions of WordPress catch fatal errors and show a "critical error" page with recovery mode for administrators, but that is a safety net, not a test plan. Always activate new code on a local site first.

If a broken plugin locks you out, rename its folder over SFTP or with wp plugin deactivate myplugin --skip-plugins. WordPress deactivates a plugin whose folder it cannot find.

The complete main file

PHP
<?php
/**
 * Plugin Name:       My Plugin
 * Plugin URI:        https://example.com/myplugin/
 * Description:       Events, bookings and booking tools for the course site.
 * Version:           0.1.0
 * Requires at least: 6.5
 * Requires PHP:      8.1
 * Author:            Your Name
 * Author URI:        https://example.com/
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       myplugin
 * Domain Path:       /languages
 */

defined( 'ABSPATH' ) || exit;

define( 'MYPLUGIN_VERSION', '0.1.0' );
define( 'MYPLUGIN_PATH', plugin_dir_path( __FILE__ ) );
define( 'MYPLUGIN_URL', plugin_dir_url( __FILE__ ) );

require_once MYPLUGIN_PATH . 'includes/post-types.php';

add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), 'myplugin_action_links' );

/**
 * Add an "Events" link next to Deactivate on the Plugins screen.
 *
 * @param array $actions Existing action links.
 * @return array
 */
function myplugin_action_links( array $actions ): array {
    $actions['myplugin_events'] = sprintf(
        '<a href="%s">%s</a>',
        esc_url( admin_url( 'edit.php?post_type=myplugin_event' ) ),
        esc_html__( 'Events', 'myplugin' )
    );

    return $actions;
}

There is no closing ?>. Leaving it out of PHP-only files means no stray whitespace can ever be sent as output.

Summary

  • A plugin is a PHP file with a Plugin Name: header, in wp-content/plugins or one folder down; use a folder with a main file of the same name.
  • Fill in Version, Requires at least, Requires PHP, License and Text Domain from the start.
  • Guard every PHP file with defined( 'ABSPATH' ) || exit; and define path, URL and version constants once in the main file.
  • Activate and inspect plugins with the admin or wp plugin activate, wp plugin list and wp eval.
  • Activation errors usually mean a missing header, a fatal error or unexpected output; read debug.log and leave out the closing ?>.

In the next lesson you will organise the plugin's files so the main file stays small as the plugin grows.

Further reading