Building a large WordPress plugin can quickly become a tangled mess if you don’t plan your structure. The best way to keep things manageable and maintainable is by using Object-Oriented Programming (OOP) and Dependency Injection (DI). In a nutshell, this approach helps you organize your code into distinct, reusable units, making it easier to understand, test, and extend your plugin without creating a monstrous, monolithic block of code.
WordPress, by its nature, is procedural. While this is great for quick, simple themes and plugins, it falls short when you’re building something substantial. Without a structured approach, you end up with global variables everywhere, functions that do too much, and a codebase that’s a nightmare to refactor or debug.
The Problem with Procedural WordPress
Imagine a plugin that manages user subscriptions, integrates with two different payment gateways, handles email notifications, and has a custom admin area. If you build this procedurally, you’ll likely have:
- Global Spaghetti: Functions directly calling other functions, often relying on global variables. This makes it hard to track data flow and introduces hidden dependencies.
- Monolithic Functions: A single function might be responsible for fetching data, processing it, and then rendering HTML. This violates the Single Responsibility Principle, making the function hard to test and change.
- Tight Coupling: Components are directly dependent on each other, meaning a change in one part of the code can unexpectedly break another.
- Difficulty in Testing: Without clear boundaries, isolated unit testing becomes nearly impossible. You often have to spin up a full WordPress environment for every test.
How OOP Helps
OOP provides tools to tackle these issues:
- Encapsulation: Grouping related data and methods into objects (classes). This hides internal complexity and exposes a clear interface for interaction.
- Abstraction: Focusing on essential features and hiding implementation details. Think of a remote control – you know what the buttons do, but not how they work internally.
- Inheritance: Creating new classes based on existing ones, promoting code reuse.
- Polymorphism: Allowing objects of different classes to be treated as objects of a common type, enabling flexible and extensible systems.
In practice, this means breaking your plugin into smaller, self-contained classes, each responsible for a specific task.
The Power of Dependency Injection
Dependency Injection takes OOP a step further. It’s a design pattern where, instead of a class creating its own dependencies, those dependencies are “injected” into it from an external source.
- Decoupling: Classes become less dependent on their concrete implementations. Instead of
MyClassdirectly instantiatingDatabaseService,DatabaseServiceis passed toMyClass(usually through its constructor). - Easier Testing: You can easily swap out real dependencies for mock objects during testing, allowing you to unit test classes in isolation.
- Improved Maintainability: Changes in one dependency don’t necessarily require changes in every class that uses it, as long as the interface remains consistent.
- Increased Flexibility: You can easily switch between different implementations of a dependency without altering the core logic of the consuming class. For instance, you could swap a Stripe payment gateway for a PayPal one by just changing which concrete class is injected.
It might seem like a lot to take in, but once you start applying these principles, your code will become significantly cleaner and easier to work with.
If you’re interested in learning more about structuring a large WordPress plugin using Object-Oriented Programming (OOP) and dependency injection, you might find this related article helpful: Contact Sheryar. This resource provides additional insights and best practices that can enhance your understanding of plugin development in WordPress, ensuring your code is both efficient and maintainable.
Setting Up Your Plugin’s Core Structure
Before diving into specific classes, let’s lay out a foundational directory and file structure. This provides a home for everything and keeps your plugin organized from the start.
Directory Structure
A good starting point for a large plugin might look something like this:
“`
your-plugin-name/
├── your-plugin-name.php # Main plugin file
├── composer.json # Composer configuration for dependencies and autoloading
├── uninstall.php # Cleanup on uninstall
├── assets/ # Styles, scripts, images for public-facing elements
│ ├── css/
│ ├── js/
│ └── img/
├── admin/ # Admin-specific classes, views, assets
│ ├── Assets.php
│ ├── Menu.php
│ ├── SettingsPage.php
│ └── views/
│ └── settings.php
├── includes/ # Core classes, interfaces, generic utilities
│ ├── AbstractController.php # Base controller class
│ ├── Installer.php # Database setup, initial data
│ ├── Enqueuer.php # Handles script/style enqueuing
│ ├── Config.php # Global configuration access
│ ├── Services/ # Services provide specific functionalities
│ │ ├── PaymentGateway/
│ │ │ ├── InterfacePaymentGateway.php
│ │ │ ├── StripeGateway.php
│ │ │ └── PayPalGateway.php
│ │ └── EmailService.php
│ ├── Entities/ # Data structures/models
│ │ └── Subscription.php
│ ├── Traits/ # Reusable chunks of functionality
│ │ └── SingletonTrait.php
│ └── Helper.php # Generic helper functions (use sparingly, prefer classes)
├── public/ # Public-facing classes, shortcodes, widgets
│ ├── Shortcodes/
│ │ └── SubscriptionShortcode.php
│ └── Widgets/
│ └── MyWidget.php
├── i18n/ # Translation files (.po, .mo)
├── templates/ # Customizable template files (if applicable)
└── vendor/ # Composer-managed third-party dependencies (avoid committing this)
“`
This structure clearly separates concerns: admin vs. public, core logic vs. assets, templates vs. configurations.
The Main Plugin File (your-plugin-name.php)
This file is the entry point for your plugin. It should do very little other than:
- Define constants.
- Include the Composer autoloader.
- Bootstrap your plugin by instantiating your main plugin class.
- Handle activation/deactivation hooks.
“`php
/**
- Plugin Name: Your Awesome Plugin
- Plugin URI: https://yourwebsite.com
- Description: A powerful plugin demonstrating OOP and DI.
- Version: 1.0.0
- Author: Your Name
- Author URI: https://yourwebsite.com
- License: GPL-2.0+
- License URI: http://www.gnu.org/licenses/gpl-2.0.txt
*/
// Don’t allow direct access
if ( ! defined( ‘ABSPATH’ ) ) {
exit;
}
// Define constants
define( ‘YOUR_PLUGIN_VERSION’, ‘1.0.0’ );
define( ‘YOUR_PLUGIN_DIR’, plugin_dir_path( __FILE__ ) );
define( ‘YOUR_PLUGIN_URL’, plugin_dir_url( __FILE__ ) );
// Load Composer autoloader
if ( file_exists( YOUR_PLUGIN_DIR . ‘vendor/autoload.php’ ) ) {
require_once YOUR_PLUGIN_DIR . ‘vendor/autoload.php’;
} else {
// Optionally, display an admin notice if composer isn’t set up
add_action( ‘admin_notices’, function() {
echo ‘
Your Awesome Plugin requires Composer dependencies. Please run composer install in the plugin directory.
‘;
});
return; // Don’t proceed without dependencies
}
use YourPluginNamespace\Plugin;
use YourPluginNamespace\Includes\Installer;
/**
- Initialize the plugin.
*/
function your_plugin_run() {
$plugin = new Plugin();
$plugin->run();
}
// Register activation and deactivation hooks
register_activation_hook( __FILE__, [ Installer::class, ‘activate’ ] );
register_deactivation_hook( __FILE__, [ Installer::class, ‘deactivate’ ] );
// Run the plugin
your_plugin_run();
“`
Notice the use of a namespace (YourPluginNamespace). This is crucial for avoiding naming collisions with other plugins and themes. Composer’s autoloader (specifically PSR-4) will map this namespace to your includes directory.
Composer Setup (composer.json)
If you’re not using Composer, you’re missing out. It handles dependency management and, more importantly for a large plugin, autoloading your classes.
“`json
{
“name”: “your-author/your-plugin-name”,
“description”: “A powerful WordPress plugin built with OOP and Dependency Injection.”,
“type”: “wordpress-plugin”,
“license”: “GPL-2.0-or-later”,
“authors”: [
{
“name”: “Your Name”,
“email”: “your@email.com”
}
],
“minimum-stability”: “stable”,
“require”: {
“php”: “>=7.4”
// Add any external libraries your plugin depends on here, e.g.:
// “stripe/stripe-php”: “^7.0”
},
“autoload”: {
“psr-4”: {
“YourPluginNamespace\\”: “includes/”
},
“files”: [
“includes/Helper.php” // If you have isolated helper functions not in classes
]
},
“config”: {
“allow-plugins”: {
“composer/installers”: true
}
}
}
“`
The autoload section is key. It tells Composer that any class in the YourPluginNamespace namespace can be found within the includes/ directory.
Core Plugin Class and Service Container
The Plugin class is the central orchestrator, responsible for bootstrapping your entire application. This is also where you’ll typically set up your Dependency Injection Container (DIC).
The Main Plugin Class (includes/Plugin.php)
This class acts as the entry point for your entire application. Its primary responsibilities are:
- Setting up the Dependency Injection Container.
- Registering core services and components into the container.
- Instantiating and “running” key services (e.g., admin menu, public shortcodes).
- Registering basic WordPress hooks (e.g.,
plugins_loaded).
“`php
namespace YourPluginNamespace;
use Pimple\Container; // We’ll use Pimple for simplicity; any DIC works.
use YourPluginNamespace\Includes\Installer;
use YourPluginNamespace\Includes\Enqueuer;
use YourPluginNamespace\Admin\Menu;
use YourPluginNamespace\Admin\SettingsPage;
use YourPluginNamespace\PublicComponent\Shortcodes\SubscriptionShortcode;
use YourPluginNamespace\Includes\Services\EmailService;
use YourPluginNamespace\Includes\Services\PaymentGateway\StripeGateway;
use YourPluginNamespace\Includes\Services\PaymentGateway\PayPalGateway;
class Plugin {
/**
- The Dependency Injection Container.
*
- @var Container
*/
private $container;
/**
- @var array An array of services to register with WordPress.
*/
protected $services_to_run = [];
public function __construct() {
$this->container = new Container();
$this->define_constants();
$this->setup_dependencies();
$this->register_services_to_run();
}
/**
- Define plugin constants.
*/
private function define_constants() {
// You might move these to a Config class or leave here for now.
// For example: if ( ! defined( ‘MY_CUSTOM_CONSTANT’ ) ) define( ‘MY_CUSTOM_CONSTANT’, ‘value’ );
}
/**
- Set up the dependency injection container.
*/
private function setup_dependencies() {
// Core services
$this->container[‘enqueuer’] = function( $c ) {
return new Enqueuer();
};
$this->container[’email_service’] = function( $c ) {
return new EmailService();
};
// Payment Gateways (example of an abstract factory pattern via DI)
// You’d likely have a factory to return the correct gateway based on settings
$this->container[‘stripe_gateway’] = function( $c ) {
return new StripeGateway( ‘sk_test_YOUR_STRIPE_SECRET’ ); // Real secret from config
};
$this->container[‘paypal_gateway’] = function( $c ) {
return new PayPalGateway( ‘client_id’, ‘client_secret’ ); // Real credentials
};
// Admin components
$this->container[‘admin_menu’] = function( $c ) {
return new Menu();
};
$this->container[‘settings_page’] = function( $c ) {
return new SettingsPage( $c[‘admin_menu’] );
};
// Public components
$this->container[‘subscription_shortcode’] = function( $c ) {
return new SubscriptionShortcode( $c[’email_service’] ); // Shortcode needs email service
};
// … add more services as needed
}
/**
- Register services that need to start executing WordPress hooks.
- These services typically have a ‘register_hooks()’ method.
*/
private function register_services_to_run() {
// These are the top-level objects that interact with WordPress hooks.
// Add instances of services/components that need to register their own hooks.
$this->services_to_run[] = $this->container[‘enqueuer’];
$this->services_to_run[] = $this->container[‘admin_menu’];
$this->services_to_run[] = $this->container[‘settings_page’]; // Settings page has its own hooks (render, save)
$this->services_to_run[] = $this->container[‘subscription_shortcode’];
// You wouldn’t directly run payment gateways or email service here,
// as they are typically used by other services, not directly hooked.
}
/**
- Run all registered services.
*/
public function run() {
// Hook into ‘plugins_loaded’ if you need to ensure all WordPress is loaded
add_action( ‘plugins_loaded’, [ $this, ‘on_plugins_loaded’ ] );
// This ensures the installer is ready for activation hook
// Installer::register_hooks(); // Or just call its static activate/deactivate
}
/**
- Executed when ‘plugins_loaded’ action fires.
*/
public function on_plugins_loaded() {
foreach ( $this->services_to_run as $service ) {
if ( method_exists( $service, ‘register_hooks’ ) ) {
$service->register_hooks();
}
}
// Load text domain after all plugins are loaded
load_plugin_textdomain( ‘your-plugin-textdomain’, false, YOUR_PLUGIN_DIR . ‘i18n/’ );
}
/**
- Get the DI container.
- Useful for debugging or if you must access objects directly.
- However, prefer injecting dependencies directly.
*/
public function get_container(): Container {
return $this->container;
}
}
“`
Dependency Injection Container (DIC)
The Container class from Pimple (or any other DIC like Symfony DI, PHP-DI) is where you define how your classes are instantiated and what their dependencies are.
- Service Definition: Each entry in
$this->containerdefines a “service.” - Lazy Loading: Pimple (and most DICs) only create the object when it’s first requested (
$c['enqueuer']), saving resources. - Dependency Resolution: When
SubscriptionShortcodeis requested, Pimple sees it needsemail_service, so it first resolvesemail_serviceand then passes it toSubscriptionShortcode‘s constructor.
Using a DIC might feel like an extra step initially, but it’s the cornerstone of a truly maintainable and testable codebase. It allows you to manage the entire “object graph” of your application in one central place.
Best Practices for Class Design
Now that we have the structure and the DI container, let’s look at how to design individual classes effectively.
Single Responsibility Principle (SRP)
This is perhaps the most important principle for large applications: A class should have only one reason to change.
- Bad Example: A
ProductManagerclass that fetches products from the database, validates user input, calculates discounts, and renders product HTML. If validation rules change, discount logic changes, or HTML changes, this class needs modification. - Good Example:
ProductRepository(handles database interaction).ProductValidator(ensures product data is valid).DiscountCalculator(applies discount logic).ProductRenderer(generates display for products).
Each of these has a single, focused responsibility.
Interface-Oriented Programming
Define contracts (interfaces) for your major services. This is especially useful for components you might want to swap out, like payment gateways or notification services.
“`php
// includes/Services/PaymentGateway/InterfacePaymentGateway.php
namespace YourPluginNamespace\Includes\Services\PaymentGateway;
interface InterfacePaymentGateway {
public function process_payment( float $amount, array $card_details ): array;
public function refund_payment( string $transaction_id, float $amount ): bool;
public function get_name(): string;
}
// includes/Services/PaymentGateway/StripeGateway.php
namespace YourPluginNamespace\Includes\Services\PaymentGateway;
class StripeGateway implements InterfacePaymentGateway {
private $api_key;
public function __construct( string $api_key ) {
$this->api_key = $api_key;
// Initialize Stripe API client here
}
public function process_payment( float $amount, array $card_details ): array {
// Stripe specific payment processing logic
return [‘status’ => ‘success’, ‘transaction_id’ => ‘123’];
}
public function refund_payment( string $transaction_id, float $amount ): bool {
// Stripe specific refund logic
return true;
}
public function get_name(): string {
return ‘Stripe’;
}
}
“`
Now, any class that needs a payment gateway can simply depend on InterfacePaymentGateway, and you can inject either StripeGateway or PayPalGateway without altering the consuming class.
Abstract Classes for Shared Logic
When multiple classes share common functionality but have distinct implementations for part of their logic, an abstract class can be useful.
“`php
// includes/AbstractController.php
namespace YourPluginNamespace\Includes;
/**
- Base class for controllers that manage WordPress hooks.
- Provides a common structure for registering hooks.
*/
abstract class AbstractController {
/**
- Array of actions to register with WordPress.
- Format: [‘hook_name’ => ‘method_name’, ‘priority’ => 10, ‘accepted_args’ => 1]
- Or simple: [‘hook_name’ => ‘method_name’]
- @var array
*/
protected $actions = [];
/**
- Array of filters to register with WordPress.
- Format: [‘hook_name’ => ‘method_name’, ‘priority’ => 10, ‘accepted_args’ => 1]
- Or simple: [‘hook_name’ => ‘method_name’]
- @var array
*/
protected $filters = [];
/**
- Registers all defined actions and filters with WordPress.
- This method should be called once the controller is instantiated.
*/
public function register_hooks() {
foreach ( $this->actions as $hook => $config ) {
if ( is_array( $config ) ) {
add_action( $hook, [ $this, $config[‘method’] ], $config[‘priority’] ?? 10, $config[‘accepted_args’] ?? 1 );
} else {
add_action( $hook, [ $this, $config ] );
}
}
foreach ( $this->filters as $hook => $config ) {
if ( is_array( $config ) ) {
add_filter( $hook, [ $this, $config[‘method’] ], $config[‘priority’] ?? 10, $config[‘accepted_args’] ?? 1 );
} else {
add_filter( $hook, [ $this, $config ] );
}
}
}
}
“`
Now, any class that needs to register WordPress hooks can extend AbstractController and simply define its $actions and $filters arrays.
“`php
// admin/SettingsPage.php
namespace YourPluginNamespace\Admin;
use YourPluginNamespace\Includes\AbstractController;
class SettingsPage extends AbstractController {
// … constructor and other properties …
protected $actions = [
‘admin_menu’ => ‘add_admin_menu_page’,
‘admin_post_your_plugin_save_settings’ => ‘save_settings’, // Using admin_post_ hook for form submission
];
protected $filters = [
// No filters for now
];
public function __construct( Menu $admin_menu ) {
// … inject dependencies …
$this->admin_menu = $admin_menu;
}
public function add_admin_menu_page() {
$this->admin_menu->add_page(
‘Your Plugin Settings’,
‘Your Plugin’,
‘manage_options’,
‘your-plugin-settings’,
[ $this, ‘render_settings_page’ ]
);
}
public function render_settings_page() {
// Load view template
include YOUR_PLUGIN_DIR . ‘admin/views/settings.php’;
}
public function save_settings() {
// Handle form submission and save settings
if ( ! current_user_can( ‘manage_options’ ) ) {
return;
}
if ( ! isset( $_POST[‘_wpnonce’] ) || ! wp_verify_nonce( $_POST[‘_wpnonce’], ‘your_plugin_settings_nonce’ ) ) {
wp_die( ‘Security check failed.’ );
}
// Sanitize and save data
update_option( ‘your_plugin_option_1’, sanitize_text_field( $_POST[‘option_1’] ) );
// … redirect with success/error message
wp_redirect( add_query_arg( ‘settings-updated’, ‘true’, wp_get_referer() ) );
exit;
}
}
“`
If you’re looking to enhance your WordPress plugin development skills, understanding how to structure a large plugin using OOP and dependency injection is crucial. A great resource that complements this topic is an article on optimizing your website’s performance, which you can find here. This article provides valuable insights into improving your site’s speed, an essential aspect that can be influenced by how well your plugin is designed and implemented. By combining these two areas of knowledge, you can create efficient and high-performing WordPress plugins.
Integrating with WordPress Hooks
One of the main challenges in WordPress OOP is integrating classes with the procedural hook system. The AbstractController above is a great start.
The Problem: $this Context
WordPress hooks (add_action, add_filter) expect a callable. Traditionally, this is a global function name or, in OOP, [$object, 'method_name'].
Solution: register_hooks() Method
Each class that needs to interact with WordPress hooks should implement a register_hooks() method. This method will contain all the add_action and add_filter calls for that specific class. The AbstractController pattern simplifies this greatly.
“`php
// Example of a Shortcode class
namespace YourPluginNamespace\PublicComponent\Shortcodes;
use YourPluginNamespace\Includes\AbstractController;
use YourPluginNamespace\Includes\Services\EmailService;
class SubscriptionShortcode extends AbstractController {
private EmailService $email_service;
protected $actions = [
‘wp_ajax_your_plugin_subscribe’ => ‘handle_ajax_subscribe’,
‘wp_ajax_nopriv_your_plugin_subscribe’ => ‘handle_ajax_subscribe’,
];
protected $filters = [
‘the_content’ => ‘inject_subscription_form’,
];
public function __construct( EmailService $email_service ) {
$this->email_service = $email_service;
parent::__construct(); // Call parent constructor to ensure $actions/$filters are initialized
}
public function handle_ajax_subscribe() {
// Nonce check, validation, etc.
$this->email_service->send_welcome_email( $_POST[’email’] );
wp_send_json_success( [‘message’ => ‘Subscription successful!’] );
}
public function inject_subscription_form( string $content ): string {
// In a real scenario, you’d use a pattern to detect where to inject the form
// For demonstration, let’s just append it.
if ( is_single() ) {
$content .= ‘
$content .= ‘
Subscribe to our Newsletter
‘;
$content .= ‘
‘;
$content .= ‘
‘;
$content .= ‘
‘;
}
return $content;
}
}
“`
In the Plugin‘s on_plugins_loaded() method, we iterate through the $services_to_run and call their register_hooks() method. This ensures all relevant hooks are registered at the appropriate time within WordPress’s lifecycle.
Other Hook Considerations
- Pre-WordPress hooks: For very early initialization (e.g., custom post types, taxonomies), use
init. - Admin-only hooks: Wrap your
add_action/add_filtercalls inif ( is_admin() )or ensure the class itself is only loaded whenis_admin()is true (e.g., by ensuring it’s only retrieved from the DI container in admin contexts). - AJAX hooks: Use
wp_ajax_your_actionfor logged-in users andwp_ajax_nopriv_your_actionfor logged-out users.
Managing Data: Entities and Repositories
For large plugins, directly interacting with wpdb or get_post_meta scattered throughout your code is a recipe for disaster. Using Entities and Repositories brings structure to your data layer.
Entities (Models)
An entity is a plain PHP object that represents a unit of data (e.g., a Subscription, Product, Order). It primarily holds data and contains basic getters/setters, potentially some validation logic. It should not contain any database access logic.
“`php
// includes/Entities/Subscription.php
namespace YourPluginNamespace\Includes\Entities;
class Subscription {
private ?int $id;
private int $user_id;
private string $email;
private string $status; // active, cancelled, pending
private string $start_date; // Y-m-d H:i:s
private ?string $end_date; // Y-m-d H:i:s
private ?string $payment_gateway_id;
private ?string $transaction_id;
public function __construct(
int $user_id,
string $email,
string $status = ‘pending’,
string $start_date = null, // Default to now
string $end_date = null,
string $payment_gateway_id = null,
string $transaction_id = null,
?int $id = null
) {
$this->id = $id;
$this->user_id = $user_id;
$this->email = $email;
$this->status = $status;
$this->start_date = $start_date ?? current_time( ‘mysql’ );
$this->end_date = $end_date;
$this->payment_gateway_id = $payment_gateway_id;
$this->transaction_id = $transaction_id;
}
// Getters and (if needed) Setters
public function get_id(): ?int { return $this->id; }
public function get_user_id(): int { return $this->user_id; }
public function get_email(): string { return $this->email; }
public function get_status(): string { return $this->status; }
public function get_start_date(): string { return $this->start_date; }
public function get_end_date(): ?string { return $this->end_date; }
public function get_payment_gateway_id(): ?string { return $this->payment_gateway_id; }
public function get_transaction_id(): ?string { return $this->transaction_id; }
public function set_id( int $id ): void { $this->id = $id; }
public function set_status( string $status ): void { $this->status = $status; }
// … other setters as needed
}
“`
Repositories
A repository abstracts the data storage layer. Its job is to persist and retrieve entities. It knows nothing about how the data is used, only how to store and fetch it. This hides the database specifics (like whether it’s a custom table, post meta, or an external API).
“`php
// includes/Repositories/SubscriptionRepository.php
namespace YourPluginNamespace\Includes\Repositories;
use YourPluginNamespace\Includes\Entities\Subscription;
class SubscriptionRepository {
private \wpdb $wpdb;
private string $table_name;
public function __construct( \wpdb $wpdb ) {
$this->wpdb = $wpdb;
$this->table_name = $this->wpdb->prefix . ‘your_plugin_subscriptions’;
}
/**
- Finds a subscription by ID.
*/
public function find( int $id ): ?Subscription {
$row = $this->wpdb->get_row(
$this->wpdb->prepare( “SELECT * FROM {$this->table_name} WHERE id = %d”, $id )
);
if ( ! $row ) {
return null;
}
return $this->hydrate_subscription( $row );
}
/**
- Finds subscriptions by user ID.
- @return Subscription[]
*/
public function find_by_user_id( int $user_id ): array {
$rows = $this->wpdb->get_results(
$this->wpdb->prepare( “SELECT * FROM {$this->table_name} WHERE user_id = %d”, $user_id )
);
return array_map( [ $this, ‘hydrate_subscription’ ], $rows );
}
/**
- Saves a subscription (insert or update).
*/
public function save( Subscription $subscription ): bool {
$data = [
‘user_id’ => $subscription->get_user_id(),
’email’ => $subscription->get_email(),
‘status’ => $subscription->get_status(),
‘start_date’ => $subscription->get_start_date(),
‘end_date’ => $subscription->get_end_date(),
‘payment_gateway_id’ => $subscription->get_payment_gateway_id(),
‘transaction_id’ => $subscription->get_transaction_id(),
];
$format = [‘%d’, ‘%s’, ‘%s’, ‘%s’, ‘%s’, ‘%s’, ‘%s’];
if ( $subscription->get_id() ) {
$where = [‘id’ => $subscription->get_id()];
$where_format = [‘%d’];
$result = $this->wpdb->update( $this->table_name, $data, $where, $format, $where_format );
} else {
$result = $this->wpdb->insert( $this->table_name, $data, $format );
if ( $result ) {
$subscription->set_id( $this->wpdb->insert_id );
}
}
return $result !== false;
}
/**
- Deletes a subscription.
*/
public function delete( Subscription $subscription ): bool {
if ( ! $subscription->get_id() ) {
return false;
}
return $this->wpdb->delete( $this->table_name, [‘id’ => $subscription->get_id()], [‘%d’] ) !== false;
}
/**
- Hydrates a Subscription object from a database row.
- @param \stdClass $row
- @return Subscription
*/
private function hydrate_subscription( \stdClass $row ): Subscription {
return new Subscription(
(int) $row->user_id,
$row->email,
$row->status,
$row->start_date,
$row->end_date,
$row->payment_gateway_id,
$row->transaction_id,
(int) $row->id
);
}
}
“`
You would then register SubscriptionRepository with your DI container, injecting wpdb:
“`php
// In Plugin.php’s setup_dependencies method
$this->container[‘wpdb’] = function() {
global $wpdb;
return $wpdb;
};
$this->container[‘subscription_repository’] = function( $c ) {
return new SubscriptionRepository( $c[‘wpdb’] );
};
“`
Any service or controller needing to interact with subscriptions would then receive SubscriptionRepository in its constructor.
Testing Your Plugin
One of the biggest benefits of a well-structured OOP plugin with DI is testability.
Unit Testing with PHPUnit
Because your classes are decoupled, you can test them in isolation. If PaymentService depends on InterfacePaymentGateway, you can create a “mock” gateway for your tests that doesn’t actually connect to a payment provider.
“`php
// Example Test for a Service that uses a Payment Gateway
// tests/unit/PaymentServiceTest.php
use YourPluginNamespace\Includes\Services\PaymentGateway\InterfacePaymentGateway;
use YourPluginNamespace\Includes\Services\PaymentService;
use PHPUnit\Framework\TestCase;
class PaymentServiceTest extends TestCase {
public function testProcessPaymentCallsGateway() {
// Create a mock payment gateway
$mock_gateway = $this->createMock( InterfacePaymentGateway::class );
// Expect the process_payment method to be called once with specific arguments
$mock_gateway->expects( $this->once() )
->method( ‘process_payment’ )
->with( $this->equalTo( 100.00 ), $this->arrayHasKey( ‘card_number’ ) )
->willReturn( [‘status’ => ‘success’, ‘transaction_id’ => ‘mock_txn_123’] );
// Inject the mock into the service we want to test
$payment_service = new PaymentService( $mock_gateway );
// Call the method being tested
$result = $payment_service->process_user_payment( 100.00, [‘card_number’ => ‘1234’] );
// Assert the outcome
$this->assertTrue( $result[‘success’] );
$this->assertEquals( ‘mock_txn_123’, $result[‘transaction_id’] );
}
}
“`
You’d set up PHPUnit with a phpunit.xml configuration, typically not in a full WordPress environment for unit tests.
Integration Testing
For tests that need some WordPress functionality (e.g., custom post types registered, admin screen rendering), you’ll use a setup that loads parts of WordPress. Tools like WP-Browser (built on Codeception) or WordPress’s own Core Test Suite (adapted for plugins) can help with this. These tests are slower but ensure your components integrate correctly with WordPress.
While setting up tests might feel like overhead, it pays dividends in the long run, especially for large, complex plugins. You can refactor with confidence, knowing your tests will catch regressions.
By embracing OOP and Dependency Injection, you transform your large WordPress plugin from a potential nightmare into a well-structured, maintainable, and enjoyable project to work on. It requires a different mindset than traditional WordPress development, but the benefits in terms of code quality, flexibility, and long-term viability are immense.