Object-oriented plugin architecture

Reorganise the events plugin into namespaced classes with one job each, a small bootstrap file and predictable hook registration.

  • Intermediate
  • 15 min read
  • Updated 17 Oct 2026

What you will learn

  • Organise a plugin with classes
  • Register hooks cleanly
  • Pass dependencies through constructors instead of globals and singletons
  • Keep the main plugin file a thin bootstrap

Before you start

Prefixed functions in a few include files work well up to a point. Past a few thousand lines, the plugin becomes hard to navigate, hard to test and easy to break, because everything can call everything. This lesson moves the events plugin to a namespaced, class-based structure. The aim is not object orientation for its own sake: it is clear responsibilities, explicit dependencies and code you can test, which the next lessons rely on.

The target structure

Plain text
myplugin/
├── myplugin.php              Bootstrap: header, constants, autoloader, start
├── uninstall.php
└── src/
    ├── Plugin.php            Composes the services and registers them
    ├── Installer.php         Activation: tables and default options
    ├── Contracts/
    │   └── Hookable.php
    ├── PostTypes/
    │   └── Event.php
    ├── Admin/
    │   └── SettingsPage.php
    └── Bookings/
        ├── Repository.php    All SQL for the bookings table
        └── RestController.php

The namespace mirrors the folders: MyPlugin\Bookings\Repository lives in src/Bookings/Repository.php. That rule is what makes autoloading possible, and it means anyone can find a class from its name.

A namespace replaces the myplugin_ prefix for classes, functions and constants declared inside it. Names that WordPress stores globally still need the prefix: hook names, option names, post types, meta keys, script handles, REST namespaces and table names.

One contract for hook registration

Most classes in a plugin exist to attach behaviour to WordPress. Give them one shared method so the bootstrap can treat them alike:

PHP
<?php
// myplugin/src/Contracts/Hookable.php

namespace MyPlugin\Contracts;

defined( 'ABSPATH' ) || exit;

interface Hookable {

    /**
     * Attach the object's callbacks to WordPress hooks.
     */
    public function register(): void;
}

The rule that follows: constructors store dependencies and do nothing else. They never call add_action(), query the database or read options. Hooks are attached in register(), and real work happens when a hook fires. Objects become cheap to create, and a test can build one without side effects.

A service class

The post type registration becomes a small class:

PHP
<?php
// myplugin/src/PostTypes/Event.php

namespace MyPlugin\PostTypes;

use MyPlugin\Contracts\Hookable;

defined( 'ABSPATH' ) || exit;

final class Event implements Hookable {

    public const POST_TYPE = 'myplugin_event';

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

    public function register_post_type(): void {
        register_post_type(
            self::POST_TYPE,
            array(
                'labels'       => array(
                    'name'          => __( 'Events', 'myplugin' ),
                    'singular_name' => __( 'Event', 'myplugin' ),
                ),
                'public'       => true,
                'has_archive'  => true,
                'show_in_rest' => true,
                'rest_base'    => 'events',
                'supports'     => array( 'title', 'editor', 'thumbnail', 'custom-fields' ),
                'rewrite'      => array( 'slug' => 'events' ),
            )
        );
    }
}

Callbacks are public methods passed as array( $this, 'method_name' ). They must be public because WordPress calls them from outside the class. Method names follow the WordPress style, lower case with underscores, so the coding standards tools in lesson 19 accept them.

The repository: one home for SQL

Every query against the bookings table moves into one class. The rest of the plugin asks the repository for bookings and never writes SQL itself. When the schema changes, there is one file to update.

PHP
<?php
// myplugin/src/Bookings/Repository.php

namespace MyPlugin\Bookings;

defined( 'ABSPATH' ) || exit;

final class Repository {

    public function __construct( private \wpdb $wpdb ) {}

    /**
     * Table name, read on every call so it follows switch_to_blog().
     */
    public function table(): string {
        return $this->wpdb->prefix . 'myplugin_bookings';
    }

    public function create( int $event_id, int $user_id, int $guests, string $status = 'confirmed' ): int {
        $inserted = $this->wpdb->insert(
            $this->table(),
            array(
                'event_id'   => $event_id,
                'user_id'    => $user_id,
                'guests'     => $guests,
                'status'     => $status,
                'created_at' => current_time( 'mysql', true ),
            ),
            array( '%d', '%d', '%d', '%s', '%s' )
        );

        return false === $inserted ? 0 : (int) $this->wpdb->insert_id;
    }

    public function find( int $id ): ?array {
        $row = $this->wpdb->get_row(
            $this->wpdb->prepare( 'SELECT * FROM %i WHERE id = %d', $this->table(), $id ),
            ARRAY_A
        );

        return $row ? $row : null;
    }

    public function for_user( int $user_id, int $limit = 100, int $offset = 0 ): array {
        $rows = $this->wpdb->get_results(
            $this->wpdb->prepare(
                'SELECT * FROM %i WHERE user_id = %d ORDER BY id ASC LIMIT %d OFFSET %d',
                $this->table(),
                $user_id,
                $limit,
                $offset
            ),
            ARRAY_A
        );

        return $rows ? $rows : array();
    }

    public function delete( int $id ): bool {
        return (bool) $this->wpdb->delete( $this->table(), array( 'id' => $id ), array( '%d' ) );
    }
}

The repository receives $wpdb through its constructor instead of reaching for global $wpdb in every method. The behaviour is the same, but the dependency is visible, and a test can pass in whatever database object it needs. The constructor uses PHP 8's promoted properties to declare and assign $wpdb in one line.

A controller that depends on the repository

The REST route for creating a booking needs the repository, so it asks for one:

PHP
<?php
// myplugin/src/Bookings/RestController.php

namespace MyPlugin\Bookings;

use MyPlugin\Contracts\Hookable;
use MyPlugin\PostTypes\Event;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;

defined( 'ABSPATH' ) || exit;

final class RestController implements Hookable {

    public function __construct( private Repository $bookings ) {}

    public function register(): void {
        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
    }

    public function register_routes(): void {
        register_rest_route(
            'myplugin/v1',
            '/events/(?P<id>\d+)/bookings',
            array(
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => array( $this, 'create_booking' ),
                'permission_callback' => array( $this, 'can_book' ),
                'args'                => array(
                    'id'     => array(
                        'type'              => 'integer',
                        'required'          => true,
                        'sanitize_callback' => 'absint',
                    ),
                    'guests' => array(
                        'type'    => 'integer',
                        'default' => 1,
                        'minimum' => 1,
                        'maximum' => 6,
                    ),
                ),
            )
        );
    }

    public function can_book(): bool|WP_Error {
        if ( ! is_user_logged_in() ) {
            return new WP_Error(
                'myplugin_login_required',
                __( 'You must be logged in to book.', 'myplugin' ),
                array( 'status' => rest_authorization_required_code() )
            );
        }

        return current_user_can( 'read' );
    }

    public function create_booking( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $event = get_post( (int) $request['id'] );

        if ( ! $event || Event::POST_TYPE !== $event->post_type || 'publish' !== $event->post_status ) {
            return new WP_Error( 'myplugin_not_found', __( 'Event not found.', 'myplugin' ), array( 'status' => 404 ) );
        }

        $booking_id = $this->bookings->create( $event->ID, get_current_user_id(), (int) $request['guests'] );

        if ( ! $booking_id ) {
            return new WP_Error( 'myplugin_booking_failed', __( 'Could not save the booking.', 'myplugin' ), array( 'status' => 500 ) );
        }

        /**
         * Fires after a booking is created through the REST API.
         *
         * @param int $booking_id Booking ID.
         * @param int $event_id   Event post ID.
         */
        do_action( 'myplugin_booking_created', $booking_id, $event->ID );

        return new WP_REST_Response( $this->bookings->find( $booking_id ), 201 );
    }
}

When the args entry has a type, minimum and maximum but no validate_callback, the REST API validates against that schema itself. The custom action keeps the plugin extensible: other code reacts to new bookings without knowing about the controller.

The settings page

Admin-only classes follow the same shape. This one wraps the Settings API code from earlier in the course:

PHP
<?php
// myplugin/src/Admin/SettingsPage.php

namespace MyPlugin\Admin;

use MyPlugin\Contracts\Hookable;

defined( 'ABSPATH' ) || exit;

final class SettingsPage implements Hookable {

    private const OPTION = 'myplugin_settings';
    private const PAGE   = 'myplugin-settings';

    public function register(): void {
        add_action( 'admin_menu', array( $this, 'add_page' ) );
        add_action( 'admin_init', array( $this, 'register_settings' ) );
    }

    public function add_page(): void {
        add_options_page(
            __( 'Events settings', 'myplugin' ),
            __( 'Events', 'myplugin' ),
            'manage_options',
            self::PAGE,
            array( $this, 'render' )
        );
    }

    public function register_settings(): void {
        register_setting(
            'myplugin_settings_group',
            self::OPTION,
            array(
                'type'              => 'array',
                'default'           => array( 'max_guests' => 6 ),
                'sanitize_callback' => static function ( $value ): array {
                    $value = is_array( $value ) ? $value : array();

                    return array( 'max_guests' => max( 1, min( 20, absint( $value['max_guests'] ?? 6 ) ) ) );
                },
            )
        );
    }

    public function render(): void {
        if ( ! current_user_can( 'manage_options' ) ) {
            return;
        }

        $settings = (array) get_option( self::OPTION, array() );
        ?>
        <div class="wrap">
            <h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
            <form action="options.php" method="post">
                <?php settings_fields( 'myplugin_settings_group' ); ?>
                <label for="myplugin-max-guests"><?php esc_html_e( 'Maximum guests per booking', 'myplugin' ); ?></label>
                <input id="myplugin-max-guests" type="number" min="1" max="20"
                    name="<?php echo esc_attr( self::OPTION ); ?>[max_guests]"
                    value="<?php echo esc_attr( (string) ( $settings['max_guests'] ?? 6 ) ); ?>">
                <?php submit_button(); ?>
            </form>
        </div>
        <?php
    }
}

Composing the plugin

One class knows which services exist and how they connect. This is the only place objects are created:

PHP
<?php
// myplugin/src/Plugin.php

namespace MyPlugin;

use MyPlugin\Admin\SettingsPage;
use MyPlugin\Bookings\Repository;
use MyPlugin\Bookings\RestController;
use MyPlugin\Contracts\Hookable;
use MyPlugin\PostTypes\Event;

defined( 'ABSPATH' ) || exit;

final class Plugin {

    private Repository $bookings;

    /** @var Hookable[] */
    private array $services = array();

    private bool $registered = false;

    public function __construct( \wpdb $wpdb ) {
        $this->bookings = new Repository( $wpdb );
    }

    public function bookings(): Repository {
        return $this->bookings;
    }

    public function register(): void {
        if ( $this->registered ) {
            return;
        }

        $this->registered = true;

        $this->services[] = new Event();
        $this->services[] = new RestController( $this->bookings );

        if ( is_admin() && apply_filters( 'myplugin_enable_settings_page', true ) ) {
            $this->services[] = new SettingsPage();
        }

        foreach ( $this->services as $service ) {
            $service->register();
        }
    }
}

Admin-only services are only created in the admin, so front-end requests do not pay for them. Lesson 25 takes this idea further.

The bootstrap file

The main file shrinks to a header, constants, an autoloader and two hooks:

PHP
<?php
/**
 * Plugin Name:       My Plugin
 * Description:       Events, bookings and reminders.
 * Version:           1.4.0
 * Requires at least: 6.5
 * Requires PHP:      8.1
 * Text Domain:       myplugin
 */

defined( 'ABSPATH' ) || exit;

define( 'MYPLUGIN_VERSION', '1.4.0' );
define( 'MYPLUGIN_DB_VERSION', '1.1' );
define( 'MYPLUGIN_PATH', plugin_dir_path( __FILE__ ) );
define( 'MYPLUGIN_URL', plugin_dir_url( __FILE__ ) );

spl_autoload_register(
    static function ( string $class_name ): void {
        $prefix = 'MyPlugin\\';

        if ( ! str_starts_with( $class_name, $prefix ) ) {
            return;
        }

        $relative = substr( $class_name, strlen( $prefix ) );
        $file     = MYPLUGIN_PATH . 'src/' . str_replace( '\\', '/', $relative ) . '.php';

        if ( is_readable( $file ) ) {
            require $file;
        }
    }
);

/**
 * The plugin instance, for other code that needs to reach a service.
 */
function myplugin(): MyPlugin\Plugin {
    static $plugin = null;

    if ( null === $plugin ) {
        global $wpdb;
        $plugin = new MyPlugin\Plugin( $wpdb );
    }

    return $plugin;
}

register_activation_hook( __FILE__, array( MyPlugin\Installer::class, 'activate' ) );

add_action(
    'plugins_loaded',
    static function (): void {
        myplugin()->register();
    }
);

The installer holds the dbDelta() code and schema version 1.1 from the custom tables lesson. That lesson's version check on plugins_loaded still applies: until lesson 20 replaces it with a migrator class, keep it as a small function in the bootstrap that calls MyPlugin\Installer::install() when myplugin_db_version is older than MYPLUGIN_DB_VERSION. Activation runs before plugins_loaded, so it must not depend on registered services:

PHP
<?php
// myplugin/src/Installer.php

namespace MyPlugin;

defined( 'ABSPATH' ) || exit;

final class Installer {

    public static function activate(): void {
        self::install();
    }

    public static function install(): void {
        global $wpdb;

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';

        $table   = $wpdb->prefix . 'myplugin_bookings';
        $charset = $wpdb->get_charset_collate();

        dbDelta(
            "CREATE TABLE {$table} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            event_id bigint(20) unsigned NOT NULL,
            user_id bigint(20) unsigned NOT NULL,
            guests tinyint(3) unsigned NOT NULL DEFAULT 0,
            status varchar(20) NOT NULL DEFAULT 'confirmed',
            created_at datetime NOT NULL,
            updated_at datetime DEFAULT NULL,
            PRIMARY KEY  (id),
            KEY event_status (event_id,status),
            KEY user_created (user_id,created_at),
            KEY created_at (created_at)
            ) {$charset};"
        );

        update_option( 'myplugin_db_version', MYPLUGIN_DB_VERSION );
    }
}

The autoloader maps a class name to a file and includes it the first time the class is used. Lesson 17 replaces it with Composer's, which does the same job with less code to maintain.

Why not a singleton everywhere?

Many plugins make every class a singleton with a static instance() method. That hides dependencies, makes classes hard to test in isolation and makes it impossible to build a second instance with different collaborators. The structure above has exactly one shared object, returned by myplugin(), and everything else receives what it needs through its constructor.

Other developers also need a way to change your plugin's behaviour. Removing a callback that was registered with array( $this, 'method' ) requires the exact object that registered it, which outside code rarely has. So offer filters for the things people commonly want to change. Plugin::register() above already does this with the myplugin_enable_settings_page filter. A site that manages settings in code can then switch the page off with one line in its own plugin:

PHP
<?php

add_filter( 'myplugin_enable_settings_page', '__return_false' );

Hooks are the public API of a WordPress plugin; classes are its internal structure. Rename and reorganise classes freely, but treat hook names and their arguments as a contract.

Summary

  • Mirror the namespace in the folder structure, MyPlugin\Bookings\Repository in src/Bookings/Repository.php, and keep prefixes on global names such as options, hooks and post types.
  • Constructors store dependencies; register() attaches hooks; work happens when the hooks fire.
  • Put all SQL for a table in one repository class, and pass $wpdb and other collaborators through constructors.
  • One composition class creates the services, and admin-only services are created only in the admin.
  • The main file is a thin bootstrap: header, constants, autoloader, activation hook and a plugins_loaded callback.
  • Offer actions and filters as the public extension points of your plugin.

Further reading