Skip to content
System nominalUTC --:--:--Articles 041
MODULE 01 Lesson 01 / 06 25 MIN

The plugin model

Anatomy of a WordPress Plugin

What makes WordPress treat a folder as a plugin, which header fields it actually reads, and why the file that carries the header should stay nearly empty.

You drop a folder into wp-content/plugins/, refresh the Plugins screen, and nothing appears. No error, no warning, no entry in the list. WordPress does not tell you that it looked and found nothing worth listing, so the failure has no message attached to it. The same silence greets you when the folder arrives but the header comment is one line short, or when the file is named something WordPress never looks at.

Plugin discovery is a directory scan with a text check at the end. Once you can predict that check exactly, the empty Plugins screen stops being a mystery and becomes a two second inspection: is there a readable .php file, and does it carry a Plugin Name: line?

A plugin is a directory with a PHP file that claims to be one

There is no plugin registry, no manifest, no build step WordPress requires. Core walks the folder, and for anything that is not itself a plugin file it opens one level of subdirectories and collects the .php files it finds. Each candidate is read for its header comment and dropped from the list if no name shows up. A plugin whose main file is missing a Plugin Name: line is invisible even though every other file in it is fine.

The smallest legal plugin is therefore a file, not a directory. Drop hello-world.php straight into wp-content/plugins/ and it appears in the list. Real plugins use a directory anyway, because a plugin that ships more than one file needs somewhere to put them, and because the directory name is what WordPress uses to group files when it checks for updates.

Two things about that scan surprise people. It looks one level deep, so my-plugin/includes/class-loader.php is never treated as a plugin of its own even though it is a readable PHP file. And a plugin that lives in a subdirectory must be listed in the active plugins option by its relative path, my-plugin/my-plugin.php, which is what the stored value looks like and what every activation and deactivation hook name is built from.

What the plugin list actually returns

get_plugins() returns an array keyed by that relative path, with the parsed header as the value. It caches the result in the object cache, so a request generally pays for the directory read once. Corrupted or half-written files are skipped rather than reported, which is another reason a plugin can vanish from the list without a message.

The header is the plugin's only contract with WordPress

Inside the main file, the header is a PHP docblock: a comment that starts with /** and contains lines of the form Field: value. WordPress parses it as plain text. It never executes the file to find out what the plugin is, which is why a plugin with a fatal error still shows up in the list with its name and version intact.

Only one field is required: Plugin Name. Everything else has a sensible default, which for most fields means empty. This file is complete and installable:

<?php
/**
 * Plugin Name: WPSifero Notes
 * Version: 0.1.0
 */

defined( 'ABSPATH' ) || exit;

The defined( 'ABSPATH' ) || exit; line is not required by WordPress but it is the first line of every serious plugin file. Requests for a plugin file directly, with the file path typed into a browser address bar, would otherwise run the file with nothing of WordPress loaded. Refusing to run without ABSPATH closes that path.

Some header fields change behaviour and the rest are labels

The header list is short enough to memorise, and the useful split is not required versus optional. It is fields WordPress acts on versus fields people read. The first group decides whether the plugin can be activated, what translations load, and where updates come from. The second group is metadata for the Plugins screen and the directory listing.

FieldWhat reads itEffect
Plugin Name Discovery The only field that must exist. Without it the plugin is not listed at all.
Requires at least Activation checks Blocks activation on older WordPress, with a message naming the required version.
Requires PHP Activation checks Same, for the PHP version. Keep it at 8.3 or newer for this course.
Requires Plugins Activation checks A comma-separated list of WordPress.org plugin slugs that must be installed and active.
Network Multisite Network: true marks the plugin as network only, shown on the Network Admin plugin screen.
Text Domain Translations Selects the translation catalogue, and the default domain for translated strings.
Domain Path Translations Where the plugin keeps its .mo files, only needed when they are not next to the main file.
Update URI Update checks Claims update responsibility. When it does not match a WordPress.org slug, core leaves updates to you.
Version Cache busting, updates Compared during update checks and used to version enqueued assets.

The rest, Plugin URI, Description, Author, Author URI, License and License URI, print on the Plugins screen or on the directory page. They change nothing about how the plugin runs. Fill them in anyway; a directory submission without them comes back to you.

Requires Plugins arrived in WordPress 6.5 and takes WordPress.org slugs, not arbitrary plugin names or paths. Its checks run when someone tries to activate the plugin, and while the plugin is active WordPress watches the list and warns when a dependency is deactivated. What it will not do is install anything from anywhere except the plugin directory, so a plugin you ship through your own update server cannot be declared there.

One header is dead. Site Wide Only was replaced by Network and using it now raises a deprecation notice. The old name is only worth recognising when you are reading plugin code written before multisite settled down.

The directory layout follows from how PHP loads the file

PHP includes your main file and starts executing from the top. Anything you want on every request has to be set up during that include, because there is no second chance later in the request to load code you forgot. Autoloading, hook registration and asset registration all either happen then or never.

That constraint pushes plugin authors towards one shape, and it is worth adopting before you have a reason to fight it:

wp-content/plugins/wpsifero-notes/
├── wpsifero-notes.php        the main file: header, constants, one require
├── uninstall.php             optional, see lesson 3
├── readme.txt                what the WordPress.org directory reads
├── includes/
│   ├── class-note-repository.php
│   ├── class-plugin.php
│   └── functions.php
├── admin/
│   ├── class-notes-screen.php
│   └── views/
│       └── notes-list.php
├── languages/
│   └── wpsifero-notes.pot
└── assets/
    └── admin.css

The main file is named after the directory so that the two agree. From inside the main file you can reach everything else with one function, plugin_dir_path( __FILE__ ), which returns the directory with a trailing slash. Never build that path from WP_PLUGIN_DIR and a hard-coded string, and never assume the directory is called what you named it locally: a site can install the plugin under a different folder name, and WordPress has supported renaming the plugins directory for years.

The main file should be thin enough to read in one screen

A main file that is thin is easier to test, easier to reason about, and easier to hand to somebody else. The goal is a file that declares what the plugin is and then hands control to a class. Everything interesting lives in includes/, behind a class that builds its own hooks.

<?php
/**
 * Plugin Name: WPSifero Notes
 * Plugin URI: https://example.test/wpsifero-notes
 * Description: Keeps short editorial notes attached to posts.
 * Version: 0.1.0
 * Requires at least: 7.0
 * Requires PHP: 8.3
 * Author: Ana Ferreiro
 * License: GPL-2.0-or-later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: wpsifero-notes
 * Domain Path: /languages
 */

defined( 'ABSPATH' ) || exit;

define( 'WPSIFERO_NOTES_VERSION', '0.1.0' );
define( 'WPSIFERO_NOTES_FILE', __FILE__ );

require_once plugin_dir_path( __FILE__ ) . 'includes/class-plugin.php';

add_action( 'plugins_loaded', static function () {
    \WPSifero\Notes\Plugin::instance()->boot();
} );

Three decisions in those twenty lines are worth naming. The constants exist so that no other file has to guess the version or the main file path. The require_once uses a path derived from __FILE__ rather than the current working directory, which is not reliable inside WordPress. And the bootstrap waits for plugins_loaded instead of running at include time, so that by the time the plugin registers its hooks every other plugin has had the same chance. Lesson 2 is about why that timing matters; for now, notice that the hook is registered during the include and the work happens later.

The instance() call above assumes a singleton, which is one way to do it and not the only defensible one. A plugin with a handful of collaborators can equally keep a module-level function that constructs the object graph and stores it in a static. What matters is that object construction happens once per request and that the plugin's dependencies are passed in rather than fetched from globals inside methods.

Plugin, must-use plugin or theme is a question about lifecycle

All three are PHP that WordPress runs, so the choice looks like taste until you look at when each one loads and who can turn it off.

PluginMust-use pluginTheme
Where it lives wp-content/plugins/ wp-content/mu-plugins/ wp-content/themes/
Loads After must-use plugins, in the stored active order First, before ordinary plugins After all plugins
Can be switched off Yes, from the admin No, only by removing the file Only by switching theme
Has activation hooks Yes No No, it has an after-switch hook
Belongs here Anything a site owner should be able to manage Site policy, security rules, environment shims Presentation: templates, styles, template parts

Must-use plugins are the odd one. WordPress loads only the .php files sitting directly in wp-content/mu-plugins/, sorted by filename, and it does not descend into subdirectories. That is why the convention is a numeric prefix: 00-wpsifero-guard.php loads before 10-wpsifero-cache.php. Because there is no activation step, an mu-plugin cannot register an activation hook that will ever fire, and it cannot be deactivated from the admin. Those two facts are the whole justification for using one.

Themes are for output. If a piece of code decides which template renders, it belongs in the theme. If it decides what data exists, what capabilities apply or how an API responds, it belongs in a plugin, because that behaviour has to survive a theme change and a site owner who wants a different look. A shortcode that prints a form is a plugin; the CSS that styles the form is usually the theme's job.

One limitation worth stating plainly: none of this explains how to run your plugin's logic when a request arrives. The header gets you listed, and the include gets your code loaded. Which hooks fire, in what order, and what is already available when each one runs is the next lesson, and it is where most bugs in a first plugin are born.

Lab 25 minutes

Build wpsifero-notes from an empty directory. Write the main file with a full header, the ABSPATH guard, a version constant and a require_once for includes/class-plugin.php. The class does not need to do anything yet; give it a static instance() and a boot() method that registers one action.

Confirm the plugin is listed in the admin with the right name and version, activate it, then do these four experiments and write down the result of each:

Rename Plugin Name to Name and reload the Plugins screen. Put it back. Move the main file into includes/ and reload. Put it back. Change Requires at least to 99.0 and try to activate. Then set Version to 0.2.0 and reload the Plugins screen without activating anything.

Finally, answer: which of those four failures produces an error message, and which produces only an absence?

Show the solution and the reasoning

The directory ends up with two files, and the split is deliberate. The main file is the only one WordPress reads the header from, and the class file is the only one that knows how the plugin behaves.

<?php
/**
 * Plugin Name: WPSifero Notes
 * Description: Keeps short editorial notes attached to posts.
 * Version: 0.1.0
 * Requires at least: 7.0
 * Requires PHP: 8.3
 * Author: Ana Ferreiro
 * License: GPL-2.0-or-later
 * Text Domain: wpsifero-notes
 */

defined( 'ABSPATH' ) || exit;

define( 'WPSIFERO_NOTES_VERSION', '0.1.0' );
define( 'WPSIFERO_NOTES_FILE', __FILE__ );

require_once plugin_dir_path( __FILE__ ) . 'includes/class-plugin.php';

add_action( 'plugins_loaded', static function () {
    \WPSifero\Notes\Plugin::instance()->boot();
} );

The class lives in a namespaced file so that two plugins on the same site cannot collide over a class called Plugin. The static property holds the single instance; boot() is where hooks belong, and it runs on plugins_loaded rather than during the include.

<?php

namespace WPSifero\Notes;

defined( 'ABSPATH' ) || exit;

final class Plugin {

    private static ?self $instance = null;

    public static function instance(): self {
        return self::$instance ??= new self();
    }

    public function boot(): void {
        add_action( 'init', array( $this, 'register_post_meta' ) );
    }

    public function register_post_meta(): void {
        // Filled in once the hooks lesson covers registration.
    }
}

Now the four experiments, in order.

Renaming Plugin Name to Name removes the plugin from the list. No message, no warning. The directory is still scanned, the header is still parsed, and the entry is discarded because the name is empty. This is the single most common reason a plugin does not appear.

Moving the main file into includes/ gives the same result. The scan collects that path as a candidate, finds no header and discards it. The main file has to sit where the scan reaches.

Requires at least: 99.0 produces an error message. The plugin appears in the list, the activate link is there, and clicking it fails with a notice naming the required version. This is the one failure in the set that talks to you, because the header is valid enough to be listed and the check happens at activation rather than at discovery.

Changing Version changes the list without any action. The header is re-read on the next request, so the new number shows up immediately. Nothing is stored at activation, which matters the next time someone assumes the version in the database is authoritative. It is not; the file is.

Progress

Mark this lesson done and it stays marked in this browser.