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.
| Field | What reads it | Effect |
|---|---|---|
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.
| Plugin | Must-use plugin | Theme | |
|---|---|---|---|
| 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.
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.