14+ years building on WordPress / Replies in under 5 hours
Development 7 min read · Updated July 2026

Beyond Global Variables: Introduction to Dependency Injection in WordPress Development

Photo of Ajay Khandal
Ajay Khandal
WordPress Developer
Beyond Global Variables: Introduction to Dependency Injection in WordPress Development
TL;DR

Dependency injection means passing a class the objects it needs through its constructor instead of reaching for WordPress globals like $wpdb from inside the class. Use constructor injection for required dependencies (public function __construct(wpdb $db)), setter injection for optional ones, and interface type-hints when you need swappable implementations. DI makes PHPUnit testing trivial — createMock(wpdb::class) replaces a real database in tests. For small plugins, a hand-written service locator is enough; for larger ones, PHP-DI reads constructors via reflection and wires everything automatically.

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, DatabaseLoggerOrderProcessor 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

  1. Identify every global $wpdb, global $wp_query, and hidden global call in your plugin classes
  2. Move each global access to the constructor parameter list — type-hint the parameter
  3. Create a ServiceLocator or bootstrap function as the single entry point that touches globals
  4. Replace concrete class type-hints with interface type-hints wherever you foresee needing swappable implementations
  5. Define an interface for every external dependency (database, mailer, logger, HTTP client)
  6. Add a NullLogger / no-op implementation of each interface to use in unit tests
  7. Use createMock( InterfaceName::class ) in PHPUnit tests instead of Brain Monkey for class-level dependencies
  8. Set up Composer PSR-4 autoloading to match your class namespace to your src/ directory structure
  9. Introduce PHP-DI (or any PSR-11 container) once manual wiring exceeds 10–15 classes
  10. 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.

Frequently asked questions

Dependency injection (DI) is a design pattern where a class receives the objects it needs (its 'dependencies') as constructor or setter parameters, rather than creating them itself or pulling them from global scope. In WordPress, this means replacing 'global $wpdb' inside a method with a typed constructor parameter: 'public function __construct(wpdb $db)'. It matters because: (1) classes with injected dependencies can be unit tested without a database by passing in a mock object; (2) the class's requirements are explicit and visible in its constructor signature; (3) you can swap one implementation for another (e.g. a different logger) without editing the class. It's the practice that separates testable, maintainable plugin code from code that becomes unmaintainable as the plugin grows.

Constructor injection passes dependencies as parameters to the class constructor — they're required and the class can't be instantiated without them. This is the right choice for any dependency the class absolutely needs to function. Setter injection passes dependencies via a 'set_*()' method called after construction — the class has a fallback behavior if the dependency isn't set. Use setter injection for optional cross-cutting concerns like logging, caching, or event dispatching, where the class can degrade gracefully without them. Constructor injection should be your default; setter injection is for the minority of genuinely optional dependencies.

A DI container (dependency injection container) is a library that reads your class constructors via PHP reflection and automatically resolves the dependency graph — you ask the container for 'PostSaver' and it figures out that PostSaver needs a wpdb instance, creates it, and returns a fully wired PostSaver. PHP-DI is the most commonly used PSR-11 container for WordPress projects. For small plugins (under ~10 classes), a hand-written service locator is simpler and has zero additional dependencies. For larger plugins with deep dependency chains, a container eliminates the manual wiring overhead. You don't need one to start; you can migrate to one later without changing your class code.

Once a class receives its dependencies through the constructor, PHPUnit's createMock() can replace any dependency with a controlled substitute in tests. A PostSaver that accepts a wpdb instance via constructor can be tested by passing createMock(wpdb::class) — the test controls exactly what the mock returns and can assert exactly how it was called, without a database. A PostSaver that calls 'global $wpdb' inside a method requires a full WordPress database environment to test. Constructor injection reduces most test setup to three lines: create the mock, set expectations, instantiate the class with the mock injected.

Interface-based DI means type-hinting a constructor parameter against an interface rather than a concrete class: '__construct(LoggerInterface $logger)' instead of '__construct(FileLogger $logger)'. You should use it whenever you foresee needing more than one implementation — a FileLogger and a NullLogger (for tests), or a production MailerService and a test stub. The concrete class never knows which implementation it receives, so adding a new implementation doesn't require changing the class. For WordPress plugins, define interfaces for any external dependency (database, mailer, HTTP client, cache, payment gateway) and use interface type-hints throughout your business logic.

The accepted pattern is to isolate the global access at your plugin's entry point — typically a service locator class or a DI container bootstrap — and inject $wpdb into every class that needs it as a constructor parameter. Only one class (the service locator or container setup) ever touches 'global $wpdb'. All business logic classes receive it as a 'wpdb $db' constructor parameter. In a PHP-DI container setup, you register the wpdb instance once: $builder->addDefinitions([wpdb::class => fn() => $GLOBALS['wpdb']]), and the container automatically passes it to any class whose constructor declares a 'wpdb' type-hint.

Photo of Ajay Khandal

Written by Ajay Khandal

I'm a freelance WordPress developer with 14+ years of experience building, fixing, and speeding up sites for businesses, agencies, and store owners across the US, UK, Europe, and Australia. I specialize in custom themes, WooCommerce, and performance — the kind of work that shows up as faster load times and fewer support tickets. No account managers, no outsourced tickets — you work directly with me, with replies typically inside 5 hours.

Work with me →