What is a Dependency — and Why WordPress Globals are the Problem
A dependency is any object or resource a class needs to do its job. In most WordPress plugins, those dependencies are grabbed implicitly from global state: global $wpdb, global $wp_query, $_POST. The code works, but the class is now secretly coupled to the environment. You can’t instantiate it in a test without the full WordPress stack loaded; you can’t swap one database implementation for another without editing the class itself.
class PostSaver {
public function save_data( array $data ): void {
global $wpdb; // tight coupling — untestable, hidden dependency
$wpdb->insert( $wpdb->prefix . 'my_table', $data );
}
}
Dependency Injection solves this by inverting the relationship: instead of a class reaching out for what it needs, something external passes in the dependencies. The class focuses only on using them.
Constructor Injection: The Standard Form
Constructor injection is the primary pattern — dependencies are declared as typed constructor parameters, which means they’re required, visible, and impossible to forget:
class PostSaver {
public function __construct( private \wpdb $db ) {}
public function save_data( array $data ): void {
$this->db->insert( $this->db->prefix . 'my_table', $data );
}
}
// Wire it up at the entry point — only one global touch, at the boundary
global $wpdb;
$post_saver = new PostSaver( $wpdb );
The class now states its contract up front: “I need a \wpdb instance.” The code calling new PostSaver() is responsible for supplying it. The global $wpdb still exists, but it’s only touched once, at the plugin’s entry point — not scattered throughout your business logic.
Setter Injection: For Optional Dependencies
Setter injection works through a set_*() method instead of the constructor. Use it when a dependency is optional — the class has a sensible fallback if the dependency isn’t provided:
class Notifier {
private ?MailerInterface $mailer = null;
// Optional dependency — class works without it (logs to error_log by default)
public function set_mailer( MailerInterface $mailer ): void {
$this->mailer = $mailer;
}
public function notify( string $message ): void {
if ( $this->mailer ) {
$this->mailer->send( $message );
} else {
error_log( $message );
}
}
}
Setter injection is appropriate for cross-cutting concerns like logging, caching, and event dispatching — things a class can degrade gracefully without. For required dependencies that the class can’t operate without, constructor injection is always the right choice.
Interface-Based DI: The Real Power
The most powerful form of DI type-hints against an interface rather than a concrete class. The class declares what it needs (a logger, a mailer, a payment gateway), not which specific implementation provides it. This is the practice that makes PHPUnit testing straightforward — in tests, you inject a no-op implementation; in production, you inject the real one:
// Define the contract — not tied to any implementation
interface LoggerInterface {
public function log( string $message ): void;
}
// Production implementation
class FileLogger implements LoggerInterface {
public function log( string $message ): void {
error_log( date('[Y-m-d H:i:s]') . ' ' . $message );
}
}
// Test implementation — does nothing
class NullLogger implements LoggerInterface {
public function log( string $message ): void {}
}
// This class never knows which logger it gets
class OrderProcessor {
public function __construct( private LoggerInterface $logger ) {}
public function process( int $order_id ): void {
// ... process order ...
$this->logger->log( "Processed order #{$order_id}" );
}
}
// Production: inject the real logger
$processor = new OrderProcessor( new FileLogger() );
// Tests: inject the no-op — no file writes, no side effects
$processor = new OrderProcessor( new NullLogger() );
When you add a second logger implementation later — SlackLogger, DatabaseLogger — OrderProcessor never changes. This is the Open/Closed Principle made concrete: open for extension, closed for modification.
Why DI Makes Testing Trivial
Once a class accepts its dependencies through its constructor, PHPUnit’s createMock() replaces them in tests with a controlled substitute. No Brain Monkey needed, no database, no WordPress environment:
use PHPUnit\Framework\TestCase;
class PostSaverTest extends TestCase {
public function test_save_data_calls_wpdb_insert(): void {
$mock_db = $this->createMock( \wpdb::class );
$mock_db->prefix = 'wp_';
$mock_db->expects( $this->once() )
->method( 'insert' )
->with( 'wp_my_table', [ 'name' => 'test' ] );
$saver = new PostSaver( $mock_db );
$saver->save_data( [ 'name' => 'test' ] );
}
}
This test runs in under 10ms and verifies the exact call made to the database. The same test against a globally-coupled class would require a full WordPress + database setup. For a deeper walkthrough of mocking strategies and GitHub Actions CI, see the WordPress PHPUnit testing guide.
The Manual Service Locator: Where to Start
For most plugins, a hand-written service locator is the right starting point — it centralises all the “which implementation goes where” decisions in one class without requiring a third-party container library. The rest of your plugin logic never touches globals.
// Plugin entry point — the one place allowed to touch globals
class MyPluginServiceLocator {
private static ?self $instance = null;
private \wpdb $db;
private function __construct() {
global $wpdb;
$this->db = $wpdb;
}
public static function instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
public function post_saver(): PostSaver {
return new PostSaver( $this->db );
}
public function order_processor(): OrderProcessor {
return new OrderProcessor( new FileLogger() );
}
}
add_action( 'init', function() {
$services = MyPluginServiceLocator::instance();
$saver = $services->post_saver();
if ( isset( $_POST['save_data'] ) ) {
$saver->save_data( sanitize_post( $_POST['my_data'] ) );
}
} );
The key insight: the service locator is the only class allowed to touch global $wpdb. Every other class receives its dependencies through the constructor. If you use Composer for autoloading, this structure maps cleanly to a PSR-4 namespace layout: service locator in the plugin root, business classes under src/.
DI Containers: Automatic Wiring for Larger Plugins
Once a plugin has more than a handful of classes, manually wiring every dependency in the service locator becomes tedious. A DI container reads class constructors via PHP reflection and resolves the dependency graph automatically. PHP-DI is the most widely used PSR-11-compatible container for WordPress projects:
composer require php-di/php-di
use DI\ContainerBuilder;
// In your plugin's bootstrap (plugins_loaded hook)
add_action( 'plugins_loaded', function() {
$builder = new ContainerBuilder();
// Register WordPress globals as resolvable types
$builder->addDefinitions( [
\wpdb::class => fn() => $GLOBALS['wpdb'],
LoggerInterface::class => \DI\create( FileLogger::class ),
] );
$container = $builder->build();
// PHP-DI reads constructors via reflection and injects automatically
// PostSaver needs \wpdb → resolved from definitions above
// OrderProcessor needs LoggerInterface → resolved to FileLogger
$container->get( PostSaver::class );
$container->get( OrderProcessor::class );
} );
$container->get( PostSaver::class ) sees that PostSaver’s constructor needs \wpdb, finds it in the definitions, and returns a fully wired instance — without you writing a single line of wiring code for PostSaver itself. As your plugin grows, new classes are automatically resolved as long as their constructor dependencies are registered. For background-processing plugins — where Action Scheduler callbacks need clean, testable handler classes — DI containers pair naturally with the Action Scheduler pattern: the container builds the handler with its dependencies injected, and the action callback calls $handler->process().
Dependency Injection in WordPress: Quick-Start Checklist
- Identify every
global $wpdb,global $wp_query, and hidden global call in your plugin classes - Move each global access to the constructor parameter list — type-hint the parameter
- Create a
ServiceLocatoror bootstrap function as the single entry point that touches globals - Replace concrete class type-hints with interface type-hints wherever you foresee needing swappable implementations
- Define an interface for every external dependency (database, mailer, logger, HTTP client)
- Add a
NullLogger/ no-op implementation of each interface to use in unit tests - Use
createMock( InterfaceName::class )in PHPUnit tests instead of Brain Monkey for class-level dependencies - Set up Composer PSR-4 autoloading to match your class namespace to your
src/directory structure - Introduce PHP-DI (or any PSR-11 container) once manual wiring exceeds 10–15 classes
- Register WordPress globals (
\wpdb,WP_Filesystem_Base) in the container definitions — keep all environment coupling at that one boundary
Dependency injection is the architectural practice that makes everything else tractable: unit tests become fast and isolated, refactors stop causing cascading breakage, and adding a second implementation of any dependency is a one-line swap at the service locator. It pairs directly with custom REST API endpoints — a well-structured handler class accepts its repository and validator as constructor arguments, making the endpoint logic independently testable from the routing layer.


