---
type: concept
title: Plugin Structure
timestamp: 2026-09-08T15:35:06+10:00
resource: https://lhero.org/?post_type=lh_kb_entry&p=141568
---
Plugin Structure
================

All LH plugins follow a consistent structure. Apply these conventions to all new and refactored plugins.

Identity methods
----------------

Every plugin class must have these static identity methods:

- `return_plugin_namespace()` — returns the plugin namespace, derived by replacing hyphens with underscores in the plugin slug (e.g. `lh-page-links-to` → `lh_page_links_to`)
- `return_plugin_text_domain()` — returns the text domain, which is the plugin slug as-is
- `plugin_name()` — returns the translated display name
- `plugin_version()` — returns the version string (not a constant — a static method returning a string literal)

See [Naming Conventions](https://lhero.org/knowledge/conventions/naming-conventions.md.md.md) for class and slug naming rules.

Singleton pattern
-----------------

```php
private static $instance = null;

public static function get_instance() {
    if ( null === self::$instance ) {
        self::$instance = new self();
    }
    return self::$instance;
}

private function __construct() {
    add_action( 'plugins_loaded', array( $this, 'plugin_init' ) );
}

```

Do not guard the class declaration itself
-----------------------------------------

Do **not** wrap a plugin’s own class declarations in a duplicate-load guard like:

```php
if ( class_exists( 'LH_Some_plugin' ) ) {
    return;
}

class LH_Some_plugin { ... }

```

This pattern is unnecessary for a plugin’s own files: WordPress already `include_once`s each active plugin file at most once per request, and this codebase’s own `include_once`-guarded includes inside `__construct()` already prevent a class from being loaded twice within one request. The guard adds no real protection here.

Worse, it has caused a genuine production incident (lh-tasks, Sept 2026): a top-level class with no `extends`/`implements` is eligible for PHP/OPcache **compile-time early binding** — the class gets linked into the class table the moment the file is compiled, independent of the runtime `if` branch wrapping it. On the affected server config, `class_exists( 'LH_Tasks_plugin' )` returned `true` on every single request, before the file’s own declaration should have “logically” been reached, so the guard fired every time and silently skipped `__construct()` — no fatal, no CPT, no hooks, nothing in the debug log. Every child class extending that same parent inherited the same failure once the parent was early-bound, since PHP appears to treat their `extends` dependency as compile-time-resolvable too.

Diagnosing this took a `debug_backtrace()` at file-top plus `( new ReflectionClass( 'X' ) )->getFileName()`/`getStartLine()` on the already-defined class — that combination gives a hard answer fast; don’t waste time on opcache-preload, duplicate-mu-plugin, or fatal-error-auto-pause theories first (all checked and ruled out before finding the real cause).

**The rule going forward:** a plugin’s own classes are never guarded this way. Reserve the `class_exists()` guard pattern strictly for genuinely shared third-party library code that more than one plugin might vendor independently (e.g. `vendor/imath/wp-statuses`, which several lh- plugins bundle their own copy of) — there, two different plugins loading two different copies of the same class name is a real scenario the guard actually protects against.

Bootstrap
---------

```php
public function plugin_init() {
    if ( ! apply_filters( self::return_plugin_namespace() . '_init_plugin', true ) ) {
        return;
    }
    // load includes, register hooks
}

```

The `apply_filters` gate allows the plugin to be disabled without deactivating it.

Hook and filter naming
----------------------

All hook and filter names must be derived from `return_plugin_namespace()`:

```php
apply_filters( self::return_plugin_namespace() . '_some_filter', $value );
do_action( self::return_plugin_namespace() . '_some_action' );

```

Filterable method return values use the filter name `namespace . '_' . method_name` verbatim, including any `return_` prefix.

File structure
--------------

- `index.php` silence files in every directory and subdirectory
- `readme.md` in plugin root with full changelog
- `languages/` directory (satisfies Domain Path header)
- `includes/` for supporting classes
- `abilities/` for MCP ability classes
- `assets/` for JS/CSS
- `vendor/` for third-party libraries (excluded from zip installs)

Versioning
----------

See [Versioning](https://lhero.org/knowledge/conventions/versioning.md.md.md).

Coding standards
----------------

See [Coding Standards](https://lhero.org/knowledge/conventions/coding-standards.md.md.md).