Plugin Best Practices
Prefix everything, autoload, guard direct access and wrap strings on the first draft, because every plugin shares one process.
defined( 'ABSPATH' ) || exit;Every plugin on a site shares one PHP process, one global namespace and one database. Nearly all plugin practice follows from that single fact.
Prefix or namespace everything
Function names, class names, constants, option names, post meta keys, transient keys, custom hook names, post type and taxonomy slugs. A PHP namespace covers functions and classes but does nothing for the database, so option and meta keys still need a literal prefix. Keep post type keys to 20 characters and taxonomy keys to 32 — core rejects anything longer with a WP_Error.
Autoload rather than require
Ship a Composer autoloader, or register one with spl_autoload_register(). A main file that require_onces twenty classes loads all of them on requests that use none.
Avoid global state
Hooks are your plugin's public surface. Pass data through filter arguments and object properties rather than a global $acme, which any other plugin can overwrite and no test can isolate.
Guard direct access
defined( 'ABSPATH' ) || exit;
// same thing, written out:
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
Internationalise from day one
Retrofitting is miserable. The text domain must be a literal string matching the Text Domain header — string extractors cannot resolve a variable or a constant. Since WordPress 6.7, calling a translation function before init raises a _load_textdomain_just_in_time notice, so build strings inside hooked callbacks, not at file scope. The escaping helpers are shorthand:
_e( 'Save', 'acme' ); // === echo __( 'Save', 'acme' );
esc_html_e( 'Save', 'acme' ); // === echo esc_html( __( 'Save', 'acme' ) );Example
<?php
/**
* Plugin Name: Acme Events
* Description: Events for Acme sites.
* Version: 2.0.0
* Requires at least: 6.4
* Requires PHP: 8.2
* Text Domain: acme-events
* Domain Path: /languages
* License: GPL-2.0-or-later
*/
namespace Acme\Events;
// Every PHP file in the plugin starts with this line. Without it, a
// misconfigured server that serves .php files directly executes the file
// with no WordPress loaded and no user context.
defined( 'ABSPATH' ) || exit;
const VERSION = '2.0.0';
const PREFIX = 'acme_events_'; // Used for options, meta keys and transients.
define( __NAMESPACE__ . '\PATH', plugin_dir_path( __FILE__ ) );
define( __NAMESPACE__ . '\FILE', __FILE__ );
/**
* PSR-4-ish autoloader: Acme\Events\Admin\Settings_Page
* -> includes/Admin/Settings_Page.php
* Classes are only read from disk when they are first referenced.
*/
spl_autoload_register( static function ( string $class ): void {
$prefix = __NAMESPACE__ . '\\';
if ( ! str_starts_with( $class, $prefix ) ) {
return; // Not ours - let another autoloader try.
}
$relative = substr( $class, strlen( $prefix ) );
$file = PATH . 'includes/' . str_replace( '\\', '/', $relative ) . '.php';
if ( is_readable( $file ) ) {
require_once $file;
}
} );
/**
* One entry point, no global $acme. Anything that needs the plugin's objects
* asks the container; anything that needs to change its behaviour uses a filter.
*/
function bootstrap(): void {
( new Post_Type() )->register_hooks();
( new Shortcode() )->register_hooks();
if ( is_admin() ) {
( new Admin\Settings_Page() )->register_hooks();
}
}
add_action( 'plugins_loaded', __NAMESPACE__ . '\\bootstrap' );When to use it
- Naming options and meta keys so a second plugin on the same site cannot collide with them.
- Splitting a plugin into classes without paying to load all of them on every request.
- Giving other developers a documented filter instead of letting them patch a global.
- Preparing a plugin for the WordPress.org directory, where i18n and direct-access guards are reviewed.
More examples
Prefixing that reaches the database
The distinction that trips up developers coming from modern PHP: a namespace is a compile-time construct and stops at the edge of your code. Option names, meta keys, transient keys, cron hook names and post type slugs are runtime strings in a shared table, and only a literal prefix keeps them apart.
<?php
namespace Acme\Events;
defined( 'ABSPATH' ) || exit;
// The namespace prefixes the class. It does NOT prefix any of these strings -
// they all end up as literal values in wp_options, wp_postmeta and the hook
// registry, where every plugin on the site shares one keyspace.
update_option( 'acme_events_settings', $settings ); // not 'settings'
set_transient( 'acme_events_feed', $feed, HOUR_IN_SECONDS ); // not 'feed'
// Leading underscore hides the meta key from the custom fields UI.
update_post_meta( $post_id, '_acme_events_starts_at', $timestamp );
// Custom hooks are part of the public API, so name them as carefully as
// a function: prefix, subject, verb.
$events = apply_filters( 'acme_events_query_results', $events, $atts );
do_action( 'acme_events_after_import', $imported_ids );
// register_post_type() rejects a key over 20 characters and
// register_taxonomy() one over 32, both with a WP_Error and a
// _doing_it_wrong() notice added in 4.2. Budget for the prefix.
register_post_type( 'acme_event', $args ); // 10 characters
// register_taxonomy( string $taxonomy, array|string $object_type, array|string $args = array() )
// $object_type is required and comes second - $args is the THIRD argument.
register_taxonomy( 'acme_event_type', 'acme_event', $args ); // 15 charactersHooks instead of globals
Constructor injection plus a register_hooks() method makes the class testable — instantiate it with a fake repository and call render() directly, no WordPress required. The documented filter is the replacement for the global other plugins used to reach into.
<?php
namespace Acme\Events;
defined( 'ABSPATH' ) || exit;
final class Shortcode {
private Repository $repository;
// Dependencies arrive through the constructor, not from a global.
public function __construct( ?Repository $repository = null ) {
$this->repository = $repository ?? new Repository();
}
public function register_hooks(): void {
// The instance is captured by the callback, so nothing needs to find it later.
add_action( 'init', array( $this, 'register_shortcode' ) );
}
public function register_shortcode(): void {
add_shortcode( 'acme_events', array( $this, 'render' ) );
}
public function render( $atts, $content = null, $tag = '' ): string {
$atts = shortcode_atts( array( 'limit' => 3 ), $atts, $tag );
$events = $this->repository->upcoming( absint( $atts['limit'] ) );
/**
* Filters the events about to be rendered.
*
* This is how another plugin extends us - not by reaching into a global.
*
* @param WP_Post[] $events Events to render.
* @param array $atts Resolved shortcode attributes.
*/
$events = apply_filters( 'acme_events_shortcode_events', $events, $atts );
return $this->to_html( $events );
}
}
// Not this: any plugin loading later can replace it, and no test can swap it out.
// global $acme_events;
// $acme_events = new Shortcode();Translatable strings done properly
Shows the three rules that make strings actually translatable: a literal text domain, numbered sprintf placeholders so word order can change, and a translators comment directly above any string with placeholders. The invalid forms at the bottom all parse and run — they just produce an empty .pot file, which is why nobody notices until a translator asks.
<?php
namespace Acme\Events;
defined( 'ABSPATH' ) || exit;
// Called from an 'init' callback or later, never at file scope: WordPress 6.7
// warns when a translation function runs before init.
function notice_text( int $count, string $name ): string {
// _n( $single, $plural, $number, $domain ) picks the form for $number.
// The translators comment must sit immediately above the call.
/* translators: 1: number of events, 2: calendar name. */
$template = _n(
'%1$d event imported from %2$s.',
'%1$d events imported from %2$s.',
$count,
'acme-events'
);
// Numbered placeholders, so a translator may reorder them.
return sprintf( $template, $count, $name );
}
// Escape at the point of output, choosing the helper for the context.
echo '<p>' . esc_html( notice_text( 3, 'Acme Calendar' ) ) . '</p>';
printf( '<a href="%s">%s</a>', esc_url( $url ), esc_html__( 'View all events', 'acme-events' ) );
printf( '<input placeholder="%s" />', esc_attr__( 'Search events', 'acme-events' ) );
// The domain must be a literal. None of these are extractable:
// __( 'Save', $domain );
// __( 'Save', ACME_TEXT_DOMAIN );
// __( $label, 'acme-events' );
Discussion