Plugin Anatomy

A plugin is any PHP file under wp-content/plugins carrying a Plugin Name header; everything else about its layout is convention.

Syntaxwp-content/plugins/my-plugin/my-plugin.php -> block comment containing "Plugin Name:" within the first 8 KB

A plugin is PHP that WordPress includes on every request once it is activated. There is no base class to extend and nothing to register with core. WordPress scans wp-content/plugins, reads the first 8 KB of each candidate file, and treats any file carrying a Plugin Name: line inside a block comment as a plugin.

The plugin header

Plugin Name is the only required field. Everything else is optional: Plugin URI, Description, Version, Author, Author URI, License, License URI, Text Domain, Domain Path, Requires at least, Requires PHP, Network, Update URI (WordPress 5.8) and Requires Plugins (WordPress 6.5). Only the main file gets a header — a Plugin Name line in an include makes WordPress list that file as a second plugin.

Single file or a directory

A single file at wp-content/plugins/acme-events.php is a complete plugin and is fine for a handful of hooks. Past that, use a directory whose main file matches the folder name — acme-events/acme-events.php — with classes in includes/, browser assets in assets/ and translations in languages/. WordPress looks for headers only in the plugins root and one level below it, so nested files are never mistaken for plugins.

Finding your own files

Paths must be derived, never hard-coded. The helpers are shorthand for expressions you could write yourself:

plugin_dir_path( __FILE__ ); // === trailingslashit( dirname( __FILE__ ) )
plugin_dir_url( __FILE__ );  // === trailingslashit( plugins_url( '', __FILE__ ) )
plugin_basename( __FILE__ ); // 'acme-events/acme-events.php'

plugin_basename() is the identifier core uses everywhere: activation hook names, the plugin list table, dependency headers.

Example

Example · php
<?php
/**
 * Plugin Name:       Acme Events
 * Plugin URI:        https://example.com/acme-events
 * Description:       Registers an events post type and a shortcode that lists them.
 * Version:           1.0.0
 * Requires at least: 6.4
 * Requires PHP:      8.2
 * Author:            Acme
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       acme-events
 * Domain Path:       /languages
 */

// Stop the file doing anything if it is requested directly over HTTP.
defined( 'ABSPATH' ) || exit;

// Resolve paths once. ACME_EVENTS_FILE is what activation hooks and
// plugin_basename() calls elsewhere in the plugin need.
define( 'ACME_EVENTS_VERSION', '1.0.0' );
define( 'ACME_EVENTS_FILE', __FILE__ );
define( 'ACME_EVENTS_PATH', plugin_dir_path( __FILE__ ) ); // /var/www/.../plugins/acme-events/
define( 'ACME_EVENTS_URL', plugin_dir_url( __FILE__ ) );   // https://site.test/wp-content/plugins/acme-events/

require_once ACME_EVENTS_PATH . 'includes/class-acme-events-post-type.php';
require_once ACME_EVENTS_PATH . 'includes/class-acme-events-shortcode.php';

// The main file wires things together. It should not do work at file scope:
// it is included before most of WordPress is ready.
add_action( 'init', array( 'Acme_Events_Post_Type', 'register' ) );
add_action( 'init', array( 'Acme_Events_Shortcode', 'register' ) );

When to use it

  • Scaffolding a new plugin so it appears correctly in the Plugins screen with version and PHP requirements.
  • Deciding whether a piece of client work belongs in a single file or a structured plugin directory.
  • Enqueuing a stylesheet shipped inside the plugin without hard-coding a wp-content URL.
  • Declaring a minimum PHP or WordPress version so core blocks activation on an unsupported host.

More examples

A single-file plugin

Dropped straight into wp-content/plugins as acme-disable-comments.php, this is a valid, activatable plugin. Reach for the single-file form when the whole plugin is a few hooks and there is nothing to autoload — a client-specific tweak, a staging-only override, an mu-plugin.

Example · php
<?php
/**
 * Plugin Name: Acme Disable Comments
 * Description: Closes comments on every post type.
 * Version:     1.0.0
 * Text Domain: acme-disable-comments
 */

defined( 'ABSPATH' ) || exit;

// 'comments_open' passes ( bool $open, int $post_id ); we ignore both and
// return false, so __return_false is the whole implementation.
add_filter( 'comments_open', '__return_false', 20 );
add_filter( 'pings_open', '__return_false', 20 );

// Hide the Comments menu item for everyone.
add_action( 'admin_menu', static function () {
	remove_menu_page( 'edit-comments.php' );
} );

Reading your own header at runtime

Keeps the version number in exactly one place — the header — instead of duplicating it in a constant that drifts. Useful for asset cache-busting and for storing a schema version. Note the require_once: get_plugin_data() is an admin function and is undefined on the front end.

Example · php
<?php
defined( 'ABSPATH' ) || exit;

/**
 * Return this plugin's version from its own header.
 *
 * get_plugin_data( string $plugin_file, bool $markup = true, bool $translate = true ): array
 * lives in wp-admin/includes/plugin.php, which is not loaded on front-end requests.
 */
function acme_events_version(): string {
	static $version = null;

	if ( null !== $version ) {
		return $version;
	}

	if ( ! function_exists( 'get_plugin_data' ) ) {
		require_once ABSPATH . 'wp-admin/includes/plugin.php';
	}

	// $markup and $translate false: we want the raw value, not HTML links.
	$data    = get_plugin_data( ACME_EVENTS_FILE, false, false );
	$version = $data['Version'];

	return $version;
}

// Used as the asset cache-buster, so a release always busts the browser cache.
add_action( 'wp_enqueue_scripts', static function () {
	wp_enqueue_style(
		'acme-events',
		ACME_EVENTS_URL . 'assets/events.css',
		array(),
		acme_events_version()
	);
} );

Adding a row action to the plugins list

Shows why plugin_basename() matters: the filter name is built from the basename, not from __FILE__ or the folder name. Add this whenever your plugin has a settings screen — users look for the link on the Plugins row before they look in the menu.

Example · php
<?php
defined( 'ABSPATH' ) || exit;

// The dynamic filter is plugin_action_links_{$plugin_file}, where $plugin_file
// is the plugin_basename form: 'acme-events/acme-events.php'.
add_filter(
	'plugin_action_links_' . plugin_basename( ACME_EVENTS_FILE ),
	/**
	 * @param string[] $actions Action links keyed by name, e.g. 'deactivate'.
	 * @return string[]
	 */
	static function ( array $actions ): array {
		$settings = sprintf(
			'<a href="%s">%s</a>',
			esc_url( admin_url( 'options-general.php?page=acme-events' ) ),
			esc_html__( 'Settings', 'acme-events' )
		);

		// Prepend so it sits before Deactivate.
		array_unshift( $actions, $settings );

		return $actions;
	}
);

Discussion

  • Be the first to comment on this lesson.