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:
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
/**
* 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:
| Field | What WordPress does with it |
|---|---|
Plugin Name | The name in the plugin list. Without it, the file is not a plugin |
Description | The text under the name on the Plugins screen |
Version | Shown in the list; your own code should also use it for cache busting and upgrades |
Requires at least | WordPress refuses to activate the plugin on older versions |
Requires PHP | WordPress refuses to activate the plugin on older PHP versions |
Author, Author URI, Plugin URI | Links and credits in the plugin list |
License, License URI | The licence; WordPress.org requires a GPL-compatible one |
Text Domain | The translation domain; must match the slug for WordPress.org translations |
Domain Path | Folder 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:
// 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_VERSIONmatches the header. Keep them in step when you release.MYPLUGIN_PATHis the server path to the plugin folder, with a trailing slash, forrequire_once.MYPLUGIN_URLis 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:
// 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:
// 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:
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:
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:
// 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:
| Message | Cause | Fix |
|---|---|---|
| The plugin does not have a valid header | No Plugin Name: line, or it is not in the first 8 KB | Put the header comment at the very top of the main file |
| Plugin could not be activated because it triggered a fatal error | A PHP error while loading the file, such as a duplicate function name | Read the error shown, or debug.log, and fix it |
| The plugin generated N characters of unexpected output during activation | Something printed output: whitespace before <?php, a closing ?> followed by a newline, or an echo | Remove stray output; omit the closing ?> tag in PHP-only files |
| This plugin requires a newer version of PHP | Requires PHP is higher than the server's version | Upgrade 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
/**
* 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, inwp-content/pluginsor one folder down; use a folder with a main file of the same name. - Fill in
Version,Requires at least,Requires PHP,LicenseandText Domainfrom 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 listandwp eval. - Activation errors usually mean a missing header, a fatal error or unexpected output; read
debug.logand 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.