Simplifying WordPress Plugin Development with Modern Object-Oriented Architecture

Most WordPress plugins start life as a single PHP file. You need a custom shortcode, a settings field, or an API webhook. You write a couple of functions, register them with add_action() and add_filter(), test the output in your browser, and move on.

Six months later, that single file has grown to 2,800 lines.

Global variables are scattered across multiple files. Functions with names like wpq_handle_post_data_custom_v2() cross-call each other across arbitrary include scripts. Database queries with raw SQL sit directly inside HTML markup strings. Debugging a single checkout bug requires tracing through fifteen nested procedural hooks with no clear entry point or dependency chain.

This is the classic WordPress “procedural hook pasta” problem.

WordPress itself was built in an era of procedural PHP. Its hook system is one of its greatest strengths–allowing millions of plugins and themes to interact without modifying core files. But procedural code does not scale well when building complex, business-critical plugins.

Applying object-oriented programming (OOP) to WordPress is not about wrapping procedural functions inside static classes and calling it a day. That is just procedural code in a tuxedo. True OOP in WordPress means establishing single-responsibility classes, managing dependencies cleanly, decoupling hook registration from class instantiation, and separating administrative logic from frontend execution.

Here is how to design and build maintainable, testable, and structured WordPress plugins using modern object-oriented PHP.


1. The Directory Structure and PSR-4 Autoloading

The first step in fixing plugin architecture is eliminating manual require_once statements. If your plugin root file contains a dozen require_once calls or dynamic scandir() loops searching for scripts, you are introducing brittle file dependencies and unnecessary filesystem overhead on every request.

Modern PHP uses Composer and the PSR-4 autoloading standard. With PSR-4, file paths directly match PHP namespaces, and classes load into memory only when they are actually referenced.

Here is the recommended project directory structure for an enterprise-ready WordPress plugin:

wp-questions/
|-- assets/
|   |-- css/
|   |   |-- admin.css
|   |   `-- public.css
|   `-- js/
|       |-- admin.js
|       `-- public.js
|-- src/
|   |-- Admin/
|   |   |-- AdminController.php
|   |   |-- SettingsPage.php
|   |   `-- views/
|   |       |-- settings-form.php
|   |       `-- question-list.php
|   |-- Core/
|   |   |-- Activator.php
|   |   |-- Deactivator.php
|   |   |-- Uninstaller.php
|   |   |-- Loader.php
|   |   `-- Plugin.php
|   |-- Public/
|   |   |-- PublicController.php
|   |   `-- Shortcodes/
|   |       `-- QuestionListShortcode.php
|   |-- Repositories/
|   |   `-- QuestionRepository.php
|   `-- Services/
|       `-- NotificationService.php
|-- composer.json
`-- wp-questions.php

Configuring composer.json

To enable PSR-4 autoloading, define your namespace mapping inside composer.json:

{
  "name": "developer/wp-questions",
  "description": "A modular question-and-answer plugin for WordPress built with OOP.",
  "type": "wordpress-plugin",
  "license": "GPL-2.0-or-later",
  "autoload": {
    "psr-4": {
      "WPQuestions\\": "src/"
    }
  },
  "require": {
    "php": ">=7.4"
  }
}

Run composer dump-autoload in your terminal. Composer will generate the optimized class map in vendor/autoload.php. From this point forward, adding a new class inside src/ requires zero manual include statements.


2. The Main Plugin Entry Point

The root file (wp-questions.php) should have one responsibility: bootstrapping the plugin. It defines metadata headers for WordPress, enforces security checks, loads the Composer autoloader, registers lifecycle hooks, and boots the main orchestrator.

Keep this file under 50 lines. It should contain no business logic, no HTML rendering, and no database queries.

<?php
/**
 * Plugin Name:       WP Questions
 * Plugin URI:        https://example.com/wp-questions
 * Description:       A modular question-and-answer platform for WordPress.
 * Version:           1.0.0
 * Requires at least: 6.0
 * Requires PHP:      7.4
 * Author:            Your Name
 * Author URI:        https://example.com
 * License:           GPL-2.0-or-later
 * License URI:       https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain:       wp-questions
 * Domain Path:       /languages
 */

namespace WPQuestions;

// Security check: prevent direct file access outside of WordPress.
if (!defined('ABSPATH')) {
    exit;
}

// Define plugin constants for paths and URLs.
define('WPQ_VERSION', '1.0.0');
define('WPQ_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('WPQ_PLUGIN_URL', plugin_dir_url(__FILE__));
define('WPQ_PLUGIN_BASENAME', plugin_basename(__FILE__));

// Require Composer PSR-4 autoloader.
if (file_exists(WPQ_PLUGIN_DIR . 'vendor/autoload.php')) {
    require_once WPQ_PLUGIN_DIR . 'vendor/autoload.php';
}

/**
 * Register lifecycle hooks.
 */
register_activation_hook(__FILE__, [Core\Activator::class, 'activate']);
register_deactivation_hook(__FILE__, [Core\Deactivator::class, 'deactivate']);

/**
 * Initialize and run the plugin on plugins_loaded.
 */
function wpq_run_plugin(): void {
    $plugin = new Core\Plugin();
    $plugin->run();
}
add_action('plugins_loaded', 'WPQuestions\\wpq_run_plugin');

Notice how we hook the plugin bootstrap to plugins_loaded. This ensures that WordPress core, translation engines, and other dependent plugins are fully loaded before our classes start executing.


3. The Hook Loader Pattern: Decoupling Registration from Instantiation

One of the most common mistakes in WordPress OOP is calling add_action() or add_filter() directly inside a class constructor:

// THE ANTI-PATTERN: Registering hooks inside constructors
class BadExampleAdmin {
    public function __construct() {
        add_action('admin_menu', [$this, 'add_settings_page']);
        add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
    }

    public function add_settings_page() { /* ... */ }
    public function enqueue_assets() { /* ... */ }
}

Why Constructor Hook Registration is Harmful

  1. Unwanted Side Effects: Simply instantiating new BadExampleAdmin() immediately modifies WordPress global hook tables. If you create an instance in a unit test or helper script, it hooks into WordPress whether you want it to or not.
  2. Untestable Code: You cannot mock, inspect, or test class methods in isolation because object construction immediately alters global runtime state.
  3. Unpredictable Order: Hook registration gets scattered across dozens of individual constructors, making it difficult to determine the exact order in which filters and actions are registered.

The Solution: The Dedicated Hook Loader

The Hook Loader pattern decouples hook definition from hook execution. The Loader class maintains internal collections of actions and filters. During plugin setup, individual controllers register their callbacks with the loader. When the plugin boots, the loader iterates through these collections and registers all hooks with WordPress in a single, clean pass.

Here is the complete implementation of src/Core/Loader.php:

<?php

namespace WPQuestions\Core;

/**
 * Register all actions and filters for the plugin.
 *
 * Maintains a list of all hooks that are registered throughout
 * the plugin, and registers them with the WordPress API during run().
 */
class Loader {

    /**
     * The array of actions registered with WordPress.
     *
     * @var array<int, array{hook: string, component: object, callback: string, priority: int, accepted_args: int}>
     */
    private array $actions = [];

    /**
     * The array of filters registered with WordPress.
     *
     * @var array<int, array{hook: string, component: object, callback: string, priority: int, accepted_args: int}>
     */
    private array $filters = [];

    /**
     * Add a new action to the collection to be registered with WordPress.
     *
     * @param string $hook          The name of the WordPress action.
     * @param object $component     A reference to the instance of the object on which the method is defined.
     * @param string $callback      The name of the method definition on the $component.
     * @param int    $priority      Optional. The priority at which the function should be fired. Default 10.
     * @param int    $accepted_args Optional. The number of arguments that should be passed to the $callback. Default 1.
     */
    public function add_action(string $hook, object $component, string $callback, int $priority = 10, int $accepted_args = 1): void {
        $this->actions[] = [
            'hook'          => $hook,
            'component'     => $component,
            'callback'      => $callback,
            'priority'      => $priority,
            'accepted_args' => $accepted_args,
        ];
    }

    /**
     * Add a new filter to the collection to be registered with WordPress.
     *
     * @param string $hook          The name of the WordPress filter.
     * @param object $component     A reference to the instance of the object on which the method is defined.
     * @param string $callback      The name of the method definition on the $component.
     * @param int    $priority      Optional. The priority at which the function should be fired. Default 10.
     * @param int    $accepted_args Optional. The number of arguments that should be passed to the $callback. Default 1.
     */
    public function add_filter(string $hook, object $component, string $callback, int $priority = 10, int $accepted_args = 1): void {
        $this->filters[] = [
            'hook'          => $hook,
            'component'     => $component,
            'callback'      => $callback,
            'priority'      => $priority,
            'accepted_args' => $accepted_args,
        ];
    }

    /**
     * Register the filters and actions with WordPress.
     */
    public function run(): void {
        foreach ($this->filters as $hook) {
            add_filter(
                $hook['hook'],
                [$hook['component'], $hook['callback']],
                $hook['priority'],
                $hook['accepted_args']
            );
        }

        foreach ($this->actions as $hook) {
            add_action(
                $hook['hook'],
                [$hook['component'], $hook['callback']],
                $hook['priority'],
                $hook['accepted_args']
            );
        }
    }

    /**
     * Get all registered actions (useful for automated testing).
     *
     * @return array
     */
    public function get_actions(): array {
        return $this->actions;
    }

    /**
     * Get all registered filters (useful for automated testing).
     *
     * @return array
     */
    public function get_filters(): array {
        return $this->filters;
    }
}

This pattern provides immediate testing benefits. In a PHPUnit test suite, you can instantiate controllers, pass them to a mock Loader, and assert that specific actions and filters were queued with expected priorities without ever executing WordPress hook functions.


4. The Visual Blueprint: Object-Oriented Plugin Design Pattern

The relationship between the entry file, orchestrator, loader, and contextual controllers is organized cleanly:

In this architecture:

  • wp-questions.php initializes the autoloader and registers lifecycle hooks.
  • Plugin.php acts as the orchestrator, instantiating domain dependencies and passing the Loader to specialized controllers.
  • Loader.php collects all hook registrations in memory and executes them once against WordPress core.
  • Context Controllers (AdminControllerPublicController) focus strictly on their domain without managing global state.
  • Repositories and Services remain completely decoupled from WordPress UI layers.

5. Avoiding Singleton Abuse and Using Constructor Injection

A frequent habit in WordPress development is turning every class into a Singleton:

// THE ANTI-PATTERN: The "Global Singleton Soup"
class QuestionRepository {
    private static ?QuestionRepository $instance = null;

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

    public function find(int $id) { /* ... */ }
}

// Called anywhere: QuestionRepository::getInstance()->find($id);

Why Singleton Overuse Creates Technical Debt

  • Hidden Dependencies: When a class calls QuestionRepository::getInstance() inside its methods, that dependency is invisible from the class signature. You cannot tell what a class needs without reading every line of its implementation.
  • Global State and Coupling: Singletons are glorified global variables. They preserve state across requests and tests, causing subtle test pollution where Test B fails only because Test A modified a singleton instance.
  • Impossible to Mock: You cannot substitute a mock repository or an in-memory test double when testing higher-level controllers.

The Professional Approach: Constructor Dependency Injection

Instead of reaching for static singletons, instantiate dependencies at the top of your application and pass them explicitly into the constructors of the classes that require them.

Let’s look at the main orchestrator class, src/Core/Plugin.php:

<?php

namespace WPQuestions\Core;

use WPQuestions\Admin\AdminController;
use WPQuestions\Public\PublicController;
use WPQuestions\Repositories\QuestionRepository;
use WPQuestions\Services\NotificationService;

/**
 * The core plugin orchestrator class.
 */
class Plugin {

    /**
     * The loader that coordinates actions and filters.
     */
    protected Loader $loader;

    /**
     * The unique identifier of this plugin.
     */
    protected string $plugin_name;

    /**
     * The current version of the plugin.
     */
    protected string $version;

    /**
     * Initialize the plugin and orchestrate dependencies.
     */
    public function __construct() {
        $this->plugin_name = 'wp-questions';
        $this->version     = WPQ_VERSION;
        $this->loader      = new Loader();

        $this->set_locale();
        $this->define_dependencies();
    }

    /**
     * Define the locale for internationalization.
     */
    private function set_locale(): void {
        $this->loader->add_action('plugins_loaded', $this, 'load_plugin_textdomain');
    }

    /**
     * Load the plugin text domain for translation.
     */
    public function load_plugin_textdomain(): void {
        load_plugin_textdomain(
            $this->plugin_name,
            false,
            dirname(WPQ_PLUGIN_BASENAME) . '/languages/'
        );
    }

    /**
     * Instantiate domain services and pass them to controllers.
     */
    private function define_dependencies(): void {
        global $wpdb;

        // Instantiate shared data repositories and services.
        $question_repository = new QuestionRepository($wpdb);
        $notification_service = new NotificationService();

        // Admin context: only instantiate administrative hooks in admin or AJAX/REST context.
        if (is_admin()) {
            $admin = new AdminController(
                $this->plugin_name,
                $this->version,
                $question_repository,
                $notification_service
            );
            $admin->register_hooks($this->loader);
        }

        // Public frontend context.
        $public = new PublicController(
            $this->plugin_name,
            $this->version,
            $question_repository
        );
        $public->register_hooks($this->loader);
    }

    /**
     * Run the loader to register all queued hooks with WordPress.
     */
    public function run(): void {
        $this->loader->run();
    }

    /**
     * Get the loader instance.
     */
    public function get_loader(): Loader {
        return $this->loader;
    }
}

Notice the advantages of this design:

  1. AdminController explicitly states its dependencies in its constructor: it needs $question_repository and $notification_service.
  2. There are no hidden calls to static singletons.
  3. If you want to write a unit test for AdminController, you can pass a mocked repository directly into new AdminController().

6. Context Segregation: Separating Admin and Public Logic

In procedural plugins, admin-specific code frequently runs during public frontend page visits. Scripts meant for the settings dashboard get evaluated on blog posts, consuming memory and processing time.

Clean OOP solves this with strict context segregation.

The Admin Controller

The AdminController is responsible for registering menu pages, enqueueing admin-only CSS/JS, and handling dashboard AJAX requests. It delegates database queries to repositories and delegates HTML rendering to dedicated template view files.

<?php

namespace WPQuestions\Admin;

use WPQuestions\Core\Loader;
use WPQuestions\Repositories\QuestionRepository;
use WPQuestions\Services\NotificationService;

/**
 * Controller handling WordPress administration logic.
 */
class AdminController {

    private string $plugin_name;
    private string $version;
    private QuestionRepository $repository;
    private NotificationService $notifier;

    public function __construct(
        string $plugin_name,
        string $version,
        QuestionRepository $repository,
        NotificationService $notifier
    ) {
        $this->plugin_name = $plugin_name;
        $this->version     = $version;
        $this->repository  = $repository;
        $this->notifier    = $notifier;
    }

    /**
     * Register admin hooks with the loader.
     */
    public function register_hooks(Loader $loader): void {
        $loader->add_action('admin_menu', $this, 'add_menu_pages');
        $loader->add_action('admin_enqueue_scripts', $this, 'enqueue_assets');
        $loader->add_action('wp_ajax_wpq_delete_question', $this, 'ajax_delete_question');
    }

    /**
     * Register top-level and submenu pages.
     */
    public function add_menu_pages(): void {
        add_menu_page(
            __('WP Questions', 'wp-questions'),
            __('Questions', 'wp-questions'),
            'manage_options',
            'wp-questions',
            [$this, 'render_dashboard_page'],
            'dashicons-format-chat',
            25
        );
    }

    /**
     * Enqueue administrative stylesheets and scripts.
     */
    public function enqueue_assets(string $hook_suffix): void {
        // Only load assets on our specific plugin settings screen.
        if ($hook_suffix !== 'toplevel_page_wp-questions') {
            return;
        }

        wp_enqueue_style(
            $this->plugin_name . '-admin',
            WPQ_PLUGIN_URL . 'assets/css/admin.css',
            [],
            $this->version
        );

        wp_enqueue_script(
            $this->plugin_name . '-admin',
            WPQ_PLUGIN_URL . 'assets/js/admin.js',
            ['jquery'],
            $this->version,
            true
        );

        wp_localize_script(
            $this->plugin_name . '-admin',
            'WPQuestionsAdminData',
            [
                'ajax_url' => admin_url('admin-ajax.php'),
                'nonce'    => wp_create_nonce('wpq_admin_nonce'),
            ]
        );
    }

    /**
     * Render the admin dashboard view.
     */
    public function render_dashboard_page(): void {
        if (!current_user_can('manage_options')) {
            wp_die(esc_html__('You do not have sufficient permissions to access this page.', 'wp-questions'));
        }

        $questions = $this->repository->get_recent(20);

        // Include clean template view.
        $view_path = WPQ_PLUGIN_DIR . 'src/Admin/views/question-list.php';
        if (file_exists($view_path)) {
            include $view_path;
        }
    }

    /**
     * Handle AJAX question deletion securely.
     */
    public function ajax_delete_question(): void {
        check_ajax_referer('wpq_admin_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => __('Permission denied.', 'wp-questions')], 403);
        }

        $question_id = isset($_POST['id']) ? absint($_POST['id']) : 0;
        if ($question_id === 0) {
            wp_send_json_error(['message' => __('Invalid question ID.', 'wp-questions')], 400);
        }

        $deleted = $this->repository->delete($question_id);
        if ($deleted) {
            wp_send_json_success(['message' => __('Question deleted successfully.', 'wp-questions')]);
        }

        wp_send_json_error(['message' => __('Failed to delete question.', 'wp-questions')], 500);
    }
}

Clean View Separation

Notice that render_dashboard_page() does not output raw HTML using 50 echo statements. It fetches data via $this->repository->get_recent(20) and includes src/Admin/views/question-list.php.

Inside src/Admin/views/question-list.php, we keep the template clean, focused strictly on escaping output and presentation:

<?php
/**
 * View template: Admin Question List
 *
 * @var array<int, object> $questions
 */

if (!defined('ABSPATH')) {
    exit;
}
?>
<div class="wrap">
    <h1 class="wp-heading-inline"><?php esc_html_e('Question Management', 'wp-questions'); ?></h1>
    <hr class="wp-header-end">

    <table class="wp-list-table widefat fixed striped">
        <thead>
            <tr>
                <th scope="col" class="manage-column"><?php esc_html_e('ID', 'wp-questions'); ?></th>
                <th scope="col" class="manage-column"><?php esc_html_e('Title', 'wp-questions'); ?></th>
                <th scope="col" class="manage-column"><?php esc_html_e('Author', 'wp-questions'); ?></th>
                <th scope="col" class="manage-column"><?php esc_html_e('Created At', 'wp-questions'); ?></th>
                <th scope="col" class="manage-column"><?php esc_html_e('Actions', 'wp-questions'); ?></th>
            </tr>
        </thead>
        <tbody>
            <?php if (empty($questions)) : ?>
                <tr>
                    <td colspan="5"><?php esc_html_e('No questions found.', 'wp-questions'); ?></td>
                </tr>
            <?php else : ?>
                <?php foreach ($questions as $question) : ?>
                    <tr id="question-row-<?php echo esc_attr((string) $question->id); ?>">
                        <td><?php echo esc_html((string) $question->id); ?></td>
                        <td><strong><?php echo esc_html($question->title); ?></strong></td>
                        <td><?php echo esc_html($question->author_name); ?></td>
                        <td><?php echo esc_html($question->created_at); ?></td>
                        <td>
                            <button 
                                type="button" 
                                class="button button-link-delete wpq-delete-btn" 
                                data-id="<?php echo esc_attr((string) $question->id); ?>"
                            >
                                <?php esc_html_e('Delete', 'wp-questions'); ?>
                            </button>
                        </td>
                    </tr>
                <?php endforeach; ?>
            <?php endif; ?>
        </tbody>
    </table>
</div>

7. Clean Lifecycle Handlers: Activation, Deactivation, and Uninstallation

Plugin activation and deactivation hooks run at distinct moments in the WordPress lifecycle. Putting hundreds of lines of table creation or rewrite flushing logic inside your main plugin entry file is an anti-pattern.

Instead, isolate each lifecycle event into a dedicated handler class inside src/Core/.

The Activator Class

Activator::activate() runs only when an administrator clicks “Activate” in the WordPress plugins dashboard. This is where you create database tables with dbDelta(), seed default options, and schedule background WP-Cron events.

<?php

namespace WPQuestions\Core;

/**
 * Fired during plugin activation.
 */
class Activator {

    /**
     * Execute activation routines.
     */
    public static function activate(): void {
        self::create_tables();
        self::seed_default_options();

        // Flag rewrite rules to be flushed safely on next init.
        set_transient('wpq_flush_rewrite_rules', true, 60);
    }

    /**
     * Create custom database tables if required.
     */
    private static function create_tables(): void {
        global $wpdb;

        $table_name      = $wpdb->prefix . 'wpq_questions';
        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
            id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
            author_id BIGINT(20) UNSIGNED NOT NULL DEFAULT 0,
            title VARCHAR(255) NOT NULL,
            content LONGTEXT NOT NULL,
            status VARCHAR(20) NOT NULL DEFAULT 'open',
            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY  (id),
            KEY author_id (author_id),
            KEY status (status)
        ) {$charset_collate};";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql);
    }

    /**
     * Set default plugin configuration options.
     */
    private static function seed_default_options(): void {
        if (get_option('wpq_allow_guest_questions') === false) {
            add_option('wpq_allow_guest_questions', '0');
        }

        if (get_option('wpq_questions_per_page') === false) {
            add_option('wpq_questions_per_page', '15');
        }
    }
}

The Deactivator Class

Deactivator::deactivate() runs when the plugin is deactivated. It cleans up scheduled cron jobs, temporary transients, and flushes rewrite rules.

Crucially: never drop user tables or delete permanent settings inside deactivation. Users frequently deactivate plugins temporarily to troubleshoot site conflicts or update dependencies.

<?php

namespace WPQuestions\Core;

/**
 * Fired during plugin deactivation.
 */
class Deactivator {

    /**
     * Execute deactivation cleanup routines.
     */
    public static function deactivate(): void {
        // Clear any scheduled background cron events.
        $timestamp = wp_next_scheduled('wpq_daily_digest_event');
        if ($timestamp) {
            wp_unschedule_event($timestamp, 'wpq_daily_digest_event');
        }

        // Clean up temporary transients.
        delete_transient('wpq_flush_rewrite_rules');

        // Flush rewrite rules on next load.
        flush_rewrite_rules();
    }
}

The Uninstaller (Zero-Footprint Cleanup)

When a user explicitly chooses to delete the plugin via the WordPress dashboard, WordPress looks for uninstall.php in the plugin root or executes a callback registered with register_uninstall_hook().

This is the only place where removing user tables and deleting configuration options from wp_options is acceptable:

<?php
/**
 * Fired when the plugin is uninstalled/deleted.
 */

// If uninstall not called from WordPress, exit.
if (!defined('WP_UNINSTALL_PLUGIN')) {
    exit;
}

global $wpdb;

// Delete custom tables.
$table_name = $wpdb->prefix . 'wpq_questions';
$wpdb->query("DROP TABLE IF EXISTS {$table_name};");

// Delete plugin options.
delete_option('wpq_allow_guest_questions');
delete_option('wpq_questions_per_page');
delete_option('wpq_version');

// Delete transients.
delete_transient('wpq_flush_rewrite_rules');

8. Writing a Testable Data Layer: The Repository Pattern

Instead of scattering $wpdb->query() or $wpdb->prepare() throughout your controllers, encapsulate all database interactions inside dedicated repository classes.

This provides three massive advantages:

  1. All SQL queries live in one place, making index optimization and query auditing straightforward.
  2. Controllers stay clean and deal only with domain models or arrays.
  3. You can easily write unit tests for your business logic by passing a mock repository that returns dummy data without needing an active MySQL connection.

Here is src/Repositories/QuestionRepository.php:

<?php

namespace WPQuestions\Repositories;

use wpdb;

/**
 * Handles database operations for Questions.
 */
class QuestionRepository {

    private wpdb $db;
    private string $table_name;

    public function __construct(wpdb $db) {
        $this->db         = $db;
        $this->table_name = $db->prefix . 'wpq_questions';
    }

    /**
     * Find a question by its primary ID.
     *
     * @param int $id
     * @return object|null
     */
    public function find(int $id): ?object {
        $query = $this->db->prepare(
            "SELECT q.*, u.display_name AS author_name 
             FROM {$this->table_name} q 
             LEFT JOIN {$this->db->users} u ON q.author_id = u.ID 
             WHERE q.id = %d LIMIT 1",
            $id
        );

        $row = $this->db->get_row($query);
        return $row ?: null;
    }

    /**
     * Retrieve recent questions.
     *
     * @param int $limit
     * @return array<int, object>
     */
    public function get_recent(int $limit = 10): array {
        $query = $this->db->prepare(
            "SELECT q.*, COALESCE(u.display_name, 'Guest') AS author_name 
             FROM {$this->table_name} q 
             LEFT JOIN {$this->db->users} u ON q.author_id = u.ID 
             ORDER BY q.created_at DESC 
             LIMIT %d",
            $limit
        );

        $results = $this->db->get_results($query);
        return is_array($results) ? $results : [];
    }

    /**
     * Insert a new question.
     *
     * @param array{author_id: int, title: string, content: string, status?: string} $data
     * @return int The inserted ID, or 0 on failure.
     */
    public function create(array $data): int {
        $inserted = $this->db->insert(
            $this->table_name,
            [
                'author_id'  => $data['author_id'],
                'title'      => sanitize_text_field($data['title']),
                'content'    => wp_kses_post($data['content']),
                'status'     => isset($data['status']) ? sanitize_key($data['status']) : 'open',
                'created_at' => current_time('mysql', true),
            ],
            ['%d', '%s', '%s', '%s', '%s']
        );

        return $inserted ? (int) $this->db->insert_id : 0;
    }

    /**
     * Delete a question by ID.
     *
     * @param int $id
     * @return bool
     */
    public function delete(int $id): bool {
        $deleted = $this->db->delete(
            $this->table_name,
            ['id' => $id],
            ['%d']
        );

        return $deleted !== false && $deleted > 0;
    }
}

Look at how cleanly $this->db->prepare() and data sanitization (sanitize_text_fieldwp_kses_post) are handled inside the repository. Your controllers do not need to know the name of the database table, how column formats work, or whether MySQL is using InnoDB or MyISAM. They simply call $this->repository->create($data) and handle the result.


9. Architectural Takeaways for Clean Plugin Development

When building WordPress plugins with object-oriented programming, adhere to these six principles:

  1. Let Composer Handle Loading: Use PSR-4 autoloading. Stop writing manual require_once statements or dynamic directory scanning loops.
  2. Never Register Hooks in Constructors: Use a dedicated Loader class to decouple hook definition from object instantiation. Keep constructors free of side effects.
  3. Inject Dependencies Explicitly: Avoid turning every helper into a static singleton. Pass database instances, settings, and repositories through class constructors.
  4. Enforce Context Boundaries: Separate administrative screens from frontend logic. Do not load admin scripts, meta boxes, or dashboard controllers on public frontend requests.
  5. Separate Views from Logic: Never mix hundred-line HTML blocks inside controller methods. Let controllers query data and include template view files.
  6. Encapsulate Data Operations: Isolate $wpdb calls and custom SQL inside dedicated repository classes. Sanitize input upon arrival and escape output upon display.

Object-oriented programming does not make WordPress plugin development more complicated. When applied with discipline, it transforms an unpredictable web of procedural callbacks into a clear, modular, and maintainable codebase that you and your team can confidently build on for years.


Changes

PassWhat changedExamples
StructureReplaced procedural tutorial with clean architectural guideAdded PSR-4 autoloading, Hook Loader engine, and Repository pattern
InflationCut promotional fluff and exaggeration“transformative potential”, “revolutionary tools” -> deleted
VocabularyReplaced AI buzzwords and metaphors“journey”, “landscape”, “delve”, “plethora” -> dropped
GrammarReplaced copula avoidance and passive phrasing“serves as” -> “is”, active voice throughout
Rhythm/StyleVaried sentence lengths and added realistic developer voice“This is the classic procedural hook pasta problem.” “Full stop.”
Hedging/FillerCut apologies and filler transitions“It is important to note that” -> removed
TransitionsReplaced generic connectors“Moreover”, “Additionally” -> natural technical flow
SoulAdded production OOP code and testing perspectivesIsolated Loader queue, Repository pattern, and Activator/Deactivator isolation

What Client Says About RoadCoderr.