Plugin structure and best practices

Organise a plugin's files, prefix everything it adds to WordPress, guard direct access and keep the main file small.

  • Intermediate
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Structure plugin files
  • Avoid naming collisions
  • Load admin-only code only where it is needed
  • Let themes override plugin templates

Before you start

A plugin that lives in one 2,000-line file works, but nobody can find anything in it, and every request parses code it does not need. A clear structure makes the plugin easy to navigate, cheap to load and safe to run next to the 30 other plugins on a typical client site.

This lesson sets the layout the course uses until the architecture lesson moves the plugin to namespaced classes.

A layout that scales

Plain text
myplugin/
    myplugin.php          Main file: header, constants, requires, activation hooks
    uninstall.php         Runs when the plugin is deleted
    readme.txt            Description, changelog, WordPress.org metadata
    includes/             Code needed on every request
        helpers.php
        post-types.php
        meta.php
        bookings.php
        shortcodes.php
    admin/                Code needed only in the admin
        menu.php
        settings.php
        notices.php
    templates/            Front-end markup, overridable by themes
        event-card.php
    assets/
        css/
        js/
    languages/            Bundled translation files, if any

The split follows one question: when is this code needed? Everything in includes/ runs on every request because the front end, the REST API, cron and the admin all need it. Code in admin/ only matters when someone is in the dashboard.

There is no single official layout. Some teams use public/ and admin/ folders, others group by feature (bookings/, events/). Choose one, write it down in the readme, and stick to it.

Keep the main file small

The main file is the plugin's front door. Its job is to declare the plugin and load the rest:

PHP
<?php
/**
 * Plugin Name:       My Plugin
 * Description:       Events, bookings and booking tools for the course site.
 * Version:           0.2.0
 * Requires at least: 6.5
 * Requires PHP:      8.1
 * License:           GPL-2.0-or-later
 * Text Domain:       myplugin
 */

defined( 'ABSPATH' ) || exit;

define( 'MYPLUGIN_VERSION', '0.2.0' );
define( 'MYPLUGIN_PATH', plugin_dir_path( __FILE__ ) );
define( 'MYPLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'MYPLUGIN_DB_VERSION', '1.0' );

require_once MYPLUGIN_PATH . 'includes/helpers.php';
require_once MYPLUGIN_PATH . 'includes/post-types.php';
require_once MYPLUGIN_PATH . 'includes/meta.php';
require_once MYPLUGIN_PATH . 'includes/bookings.php';
require_once MYPLUGIN_PATH . 'includes/shortcodes.php';

if ( is_admin() ) {
    require_once MYPLUGIN_PATH . 'admin/menu.php';
    require_once MYPLUGIN_PATH . 'admin/settings.php';
    require_once MYPLUGIN_PATH . 'admin/notices.php';
}

No function bodies, no HTML, no queries. A developer opening the plugin for the first time sees what it contains in ten seconds. Only two things must stay in the main file: the header, and the activation and deactivation hooks, because those use __FILE__ to identify the plugin. You add them in the next lesson.

What is_admin() means

is_admin() returns true when the request is for an admin screen. It is the right tool for deciding which files to load. Two details matter:

  • Requests to admin-ajax.php and admin-post.php count as admin requests, so handlers you register in admin/ still run for them.
  • REST API requests, including those from the block editor, are not admin requests. Anything the editor needs, such as registered meta, belongs in includes/.

is_admin() says nothing about the user. It is true for a subscriber who visits their profile screen. Never use it as a permission check; use current_user_can().

Guard every file

Every PHP file in the plugin starts with the same guard as the main file:

PHP
<?php
// wp-content/plugins/myplugin/includes/helpers.php
defined( 'ABSPATH' ) || exit;

Without it, someone can request https://example.com/wp-content/plugins/myplugin/admin/settings.php directly. Usually the file just fails with a fatal error about an undefined function, which leaks your server path in the error message. Occasionally a file does something on load. The guard costs nothing and removes the question.

Some plugins also add an empty index.php to each folder so that servers with directory listing turned on do not show the file list. It does no harm, but turning off directory listing on the server is the real fix.

Prefix everything

WordPress has no isolation between plugins. Every function, constant, option and hook shares one global space with core, the theme and every other plugin. Two plugins that both define get_events() cause a fatal error the moment both are active.

The fix is a unique prefix on everything that is global:

WhatExample
Functionsmyplugin_get_settings()
ConstantsMYPLUGIN_VERSION
Global variables$myplugin_cache
Options and transientsmyplugin_settings, myplugin_upcoming_count
Post types, taxonomies, meta keysmyplugin_event, myplugin_event_type, myplugin_price
Custom tables{$wpdb->prefix}myplugin_bookings
Your own hooksdo_action( 'myplugin_booking_created', $booking_id )
Cron and Action Scheduler hooksmyplugin_daily_cleanup
Script and style handlesmyplugin-admin
REST namespacesmyplugin/v1
Nonce actions, AJAX actionsmyplugin_save_event_42, myplugin_booking_status
CSS classes and HTML IDs.myplugin-event-card
Shortcodes[myplugin_events]

Choose a prefix that is specific to the plugin, at least four or five characters long. mp_ or wp_ are poor choices: short prefixes collide, and wp_ belongs to core.

Do not wrap functions in function_exists

You may see this pattern:

PHP
if ( ! function_exists( 'myplugin_get_settings' ) ) {
    function myplugin_get_settings() {
        // ...
    }
}

It hides collisions instead of preventing them. If another plugin defines the same function first, yours silently does not load and the other one runs in its place, with different behaviour. A unique prefix is the real fix. function_exists() belongs in themes that deliberately let child themes replace a function, which is a different situation.

In the architecture lesson, a PHP namespace (MyPlugin\) replaces most of these function prefixes. Options, hooks, handles and database names still need the prefix, because they are not PHP symbols.

Templates that themes can override

Front-end markup should live in template files, not in PHP strings, so designers can find it. Let themes replace your templates without editing the plugin, the same way WooCommerce does:

PHP
<?php
// wp-content/plugins/myplugin/includes/helpers.php
defined( 'ABSPATH' ) || exit;

/**
 * Load a template, preferring a copy in the active theme.
 *
 * Themes override by adding myplugin/{name}.php to their folder.
 *
 * @param string $name Template name without extension, such as 'event-card'.
 * @param array  $args Variables for the template, available as $args.
 */
function myplugin_get_template( string $name, array $args = array() ): void {
    $name     = sanitize_key( $name );
    $template = locate_template( 'myplugin/' . $name . '.php' );

    if ( ! $template ) {
        $template = MYPLUGIN_PATH . 'templates/' . $name . '.php';
    }

    if ( is_readable( $template ) ) {
        load_template( $template, false, $args );
    }
}

locate_template() looks in the child theme, then the parent theme. load_template() includes the file and makes the third argument available inside it as $args. The template itself:

PHP
<?php
// wp-content/plugins/myplugin/templates/event-card.php
defined( 'ABSPATH' ) || exit;

$event = $args['event'] ?? null;

if ( ! $event instanceof WP_Post ) {
    return;
}
?>
<article class="myplugin-event-card">
    <h3><a href="<?php echo esc_url( get_permalink( $event ) ); ?>"><?php echo esc_html( get_the_title( $event ) ); ?></a></h3>
    <p class="myplugin-event-card__date"><?php echo esc_html( get_post_meta( $event->ID, 'myplugin_event_date', true ) ); ?></p>
</article>

sanitize_key() on the name stops a stray value such as ../../wp-config from ever reaching the file path. You only call the function with literal names, but the guard keeps it safe if that changes.

Assets and translations

Keep CSS and JavaScript in assets/ and load them with MYPLUGIN_URL and MYPLUGIN_VERSION, only on the screens that need them. The WordPress course's enqueuing lesson covers the functions.

If the plugin is on WordPress.org, translations are installed from translate.wordpress.org automatically, keyed by the text domain. Make sure the Text Domain header and every __() call use myplugin. You only need languages/ and load_plugin_textdomain() for translations you ship yourself, and then call it on init or later, never at file load.

A checklist for every new file

  1. Does it start with defined( 'ABSPATH' ) || exit;?
  2. Is it in the right folder for when it is needed?
  3. Is every function, option, hook and handle prefixed?
  4. Is it loaded once from the main file, with require_once and MYPLUGIN_PATH?
  5. Does it only register hooks at load time, and do the real work inside them?

The last point matters most. Code that runs at file load runs before WordPress has finished loading: before init, before the current user is known and before translations are ready. Register hooks at load time, and do the work in the callbacks.

Summary

  • Split files by when they are needed: includes/ for every request, admin/ for the dashboard, templates/ for markup.
  • Keep the main file to the header, constants, require_once calls and activation hooks.
  • Use is_admin() to decide what to load, never to decide what a user may do; REST requests are not admin requests.
  • Guard every file with defined( 'ABSPATH' ) || exit;.
  • Prefix every global name: functions, constants, options, meta keys, tables, hooks, handles, classes and shortcodes.
  • Load templates through a helper so themes can override them.

Further reading