Modern WordPress Plugin Development

Open Source, PHP, Tips & Tricks, and Tutorials

Modern WordPress Plugin Development

Most WordPress plugin tutorials still teach the same pattern from 2010: dump every function into a single PHP file, prefix everything with your plugin slug, and hope nothing collides with the next plugin a client installs. That style works until the plugin grows past a few hundred lines, then it becomes the kind of code nobody wants to inherit.

This guide walks through a more modern approach to WordPress plugin development - the same architectural patterns you would expect in a Laravel or Symfony app, applied inside the WordPress runtime. The plugin we'll build:

  • Uses Composer and PSR-4 autoloading instead of a long list of require_once statements
  • Splits responsibilities into small classes (single responsibility principle), so each file does one thing
  • Wires everything together with a dependency injection container (PHP-DI)
  • Cleanly separates HTML view files from PHP logic
  • Ships to production through a Git deployment pipeline instead of FTP

This article assumes a working knowledge of PHP, Composer, and the basics of building a WordPress plugin (registering hooks, custom post types, shortcodes). If you are new to deploying WordPress at all, start with our guide on how to automate WordPress deployments with Git and then come back here.

Modern WordPress plugin development

Why a modern structure matters

The classic WordPress plugin pattern - one big file full of add_action calls and globally-namespaced functions - has three real-world failure modes:

  1. Collisions. Two plugins both define get_events() and the site fatals. Prefixing every function (mrd_get_events) is a workaround, not a solution.
  2. Untestable code. When business logic lives inside a hook callback that touches $wpdb and wp_remote_get directly, you can't unit test it without spinning up the entire WordPress stack.
  3. Onboarding cost. A new developer opening plugin.php and seeing 2,000 lines of mixed concerns has to read all of it before they can change anything safely.

Composer, namespaces, and a thin DI container fix all three. The plugin we'll build is around 300 lines of orchestration plus small focused classes - each one fits on a screen.

The plugin's functionality

The example plugin pulls event data from a third-party API on a schedule, caches it using WP Transients, and exposes the events through a custom post type plus two shortcodes (upcoming events and a single event). The exact business logic doesn't matter - what matters is the structure.

Setting up Composer in a WordPress plugin

Composer is the standard package manager for PHP, but most WordPress plugins still don't use it. Initialise it in the plugin root:

composer init

Enable PSR-4 autoloading so any class you drop into src/ with the right namespace is loaded on demand:

{
  "autoload": {
    "psr-4": {
      "Mrd\\": "src/"
    }
  }
}

After editing composer.json, run composer dump-autoload so the optimised classmap picks up your new namespace.

Useful dependencies for plugin work

These are not WordPress-specific, but they replace a lot of homegrown helper code:

  • Symfony VarDumper - dev dependency only. Gives you dd() and dump(), which beat var_dump() and print_r() for any non-trivial structure.
  • Laravel Collections - install with composer require illuminate/collections. Replaces nested array_map/array_filter/array_reduce chains with readable fluent calls.
  • Laravel Helpers - composer require laravel/helpers. Mostly useful for the Str string helpers.
  • Carbon - the de facto extension to PHP's DateTime. Indispensable for anything date-heavy.

For the events plugin specifically I also pulled in Symfony's DomCrawler and CSS Selector components to scrape a small piece of HTML from the upstream API (the API returns room colours as inline styles rather than as data - an annoyingly common gotcha when integrating with legacy back-office systems):

$layout  = $this->api->getLayout();
$crawler = new Crawler( $layout );
$rooms   = $crawler->filter( '#RoomColorList' )->children();

$roomArray = $rooms->each( function ( $room ) {
    $styles               = $room->attr( 'style' );
    $styleAsKeyValuePairs = array_column( array_chunk( preg_split( '/[:;]\s*/', $styles ), 2 ), 1, 0 );

    return [
        'name'  => $room->text(),
        'color' => Str::replace( '!important', '', $styleAsKeyValuePairs['background-color'] ),
    ];
} );

return collect( $roomArray );
$this->room = Container::getInstance()
    ->get( Rooms::class )
    ->findByColour( $this->color )['name'];

It is dirty - the upstream really should expose this as JSON - but it is encapsulated in a single class, which is the whole point of the structure.

Production build: don't ship composer.json

One important detail before we go further: the vendor/ directory should be built on the server during deploy, not committed to your repo. Keep composer.json, composer.lock, and src/ in Git; let your build pipeline run composer install --no-dev --optimize-autoloader and then upload only the files the live site needs. We'll cover this in the deployment section.

Dependency injection inside WordPress

WordPress itself has no DI container. Hooks call your callbacks with whatever arguments WordPress decides on, and the conventional way to share state between callbacks is either globals or singletons - both of which create exactly the testability problems we're trying to avoid.

The trick is to keep the WordPress entry points as thin as possible and have them resolve real dependencies out of a container. PHP-DI is a good fit because it supports autowiring (it works out constructor dependencies by reflection), so you don't need to wire every class by hand.

Pull it in:

composer require php-di/php-di

Then build a small singleton wrapper so the rest of the plugin can grab the container without each class knowing how it was constructed:

<?php
namespace Mrd;

use DI\ContainerBuilder;

final class Container {

    protected $container;
    protected static $instance;

    protected function __construct() {
        $builder         = new ContainerBuilder();
        $builder->useAutowiring( true );
        $this->container = $builder->build();
    }

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

        return static::$instance->container;
    }
}

Now any class registered as a singleton (or resolved through $container->get( EventRepository::class )) is shared across every shortcode, hook, and admin screen in the plugin. You fetch and cache the API data once, in one place.

Watch out: PHP-DI's compile() mode and production caching matter. In dev, autowiring is fine. For production, call $builder->enableCompilation( WP_CONTENT_DIR . '/cache/php-di' ) to compile the container, otherwise reflection runs on every request. The cache directory needs to be writable by PHP-FPM but should not be web-accessible.

The single biggest reason to do this in WordPress specifically is so that a single EventRepository instance is reused across the request - the API/cache lookup happens once, every shortcode and template tag pulls from the same in-memory dataset, and you avoid the classic plugin smell of three queries firing for the same data because three different hooks each instantiate their own thing.

If you want to dig deeper, the Torque article on automatic dependency injection in WordPress was useful background reading, and the WP Migrate plugin by Delicious Brains is a real-world production codebase that uses PHP-DI inside WordPress.

Separating HTML from PHP

The default WordPress pattern - HTML strings concatenated inside PHP, or PHP tags scattered through templates - is hard to maintain past a couple of views. A small View helper fixes it:

src/View/View.php

<?php
namespace Mrd\View;

class View {

    private $output;
    private $viewPath;
    private $data;

    public function __construct( $location, $data = null ) {
        $this->viewPath = MRD_PATH . "views/{$location}.php";
        $this->data     = $data;

        if ( ! file_exists( $this->viewPath ) ) {
            $this->output = "<p>View <strong>'{$this->viewPath}'</strong> not found</p>";
            return $this;
        }

        if ( ! empty( $this->data ) ) {
            extract( $this->data, EXTR_SKIP );
        }

        ob_start();
        include $this->viewPath;
        $this->output = ob_get_clean();
    }

    public static function render( $location, $data = null ) {
        return new self( $location, $data );
    }

    public function echo() {
        echo $this->output;
    }

    public function fetchOutput() {
        return $this->output;
    }
}

A couple of small but important details:

  • extract( $this->data, EXTR_SKIP ) is safer than the default EXTR_OVERWRITE - it won't clobber existing variables in scope.
  • Anything user-facing inside the view template must be escaped - esc_html(), esc_attr(), esc_url(), or wp_kses_post(). The View helper itself is dumb output - escaping is the view template's job.

Usage from anywhere in the plugin:

View::render( 'name', [ 'name' => 'Richard Bell' ] )->echo();

views/name.php

<h1><?php echo esc_html( $name ); ?></h1>

Git setup and deployment with DeployHQ

The plugin lives in Git. Locally you run composer install with dev dependencies; the live site needs only the production dependencies and none of the build artifacts.

For a private plugin (not destined for the WordPress.org plugin directory, so no SVN ceremony needed) the deployment story is straightforward with DeployHQ:

  1. Connect the GitHub or GitLab repo to a DeployHQ project. We support deploys from GitHub and deploys from GitLab out of the box.
  2. Add a build pipeline that runs composer install --no-dev --optimize-autoloader so the production server only gets runtime dependencies.
  3. Use exclusions to keep composer.json, composer.lock, tests/, .github/, and any local-only files out of the upload.
  4. Push to your main branch and DeployHQ deploys the cleaned plugin into wp-content/plugins/your-plugin/ on the live server.

Because DeployHQ does atomic, zero-downtime deployments, the live site never sees a half-uploaded plugin - either the new version is fully present or the old one is still serving requests. If something does go wrong, one-click rollback puts the previous release back without anyone needing to SSH in.

A typical exclusion list for a plugin like this:

.git
.gitignore
.github/
composer.json
composer.lock
node_modules/
tests/
phpunit.xml
.editorconfig
README.md

If you're deploying multiple WordPress sites, the same pipeline pattern works for themes and custom plugins together - see deploying WordPress themes automatically and the broader PHP deployment guide for the wider workflow.

Final plugin structure

After deployment the plugin layout looks like this:

your-plugin/
├── plugin.php              # WordPress entry point
├── src/                    # Autoloaded classes (PSR-4)
│   ├── Container.php
│   ├── Plugin.php
│   ├── Repositories/
│   │   └── EventRepository.php
│   ├── View/
│   │   └── View.php
│   └── Api/
│       └── EventsApi.php
├── views/                  # HTML templates
│   ├── shortcodes/
│   │   ├── upcoming.php
│   │   └── single.php
│   └── admin/
│       └── settings.php
└── vendor/                 # composer install --no-dev (built in pipeline)

The WordPress entry file stays small - it bootstraps Composer, defines the plugin path constant, and asks the container for the Plugin class. Everything else is delegated:

plugin.php

<?php
/**
 * Plugin Name: MRD Events
 * (See https://developer.wordpress.org/plugins/plugin-basics/header-requirements/)
 */

use Mrd\Container;
use Mrd\Plugin;

require_once __DIR__ . '/vendor/autoload.php';

define( 'MRD_PATH', plugin_dir_path( __FILE__ ) );

$container = Container::getInstance();
$container->get( Plugin::class );

src/Plugin.php

<?php
namespace Mrd;

class Plugin {

    public function __construct() {
        add_action( 'init', [ $this, 'registerPostType' ] );
    }

    public function registerPostType() {
        // Register custom post type
    }
}

The constructor body is the entire plugin wiring - one line per WordPress hook. Anything more complicated than that signals a missing class.

Where to take this next

Three concrete next steps once the structure is in place:

  1. Add tests. With everything DI'd, you can write PHPUnit tests that instantiate EventRepository with a fake API client and assert behaviour without needing WordPress at all. WordPress integration tests then sit on top through WP_Mock or Brain Monkey.
  2. Add static analysis. PHPStan at level 6+ with the Szepeviktor WordPress stubs catches whole categories of bugs (wrong types, missing null checks) without running the code.
  3. Add typed properties and return types. PHP 8.x supports readonly properties, union types, and constructor property promotion - all of which make plugin code dramatically easier to reason about.

If you're building plugins or themes for clients and want every push to land safely in production, DeployHQ automates the build, deploy, and rollback steps so you can focus on the plugin itself rather than the FTP. You can start a free trial or check the pricing page - the lower tiers are designed for solo developers and small agencies running a handful of WordPress sites.


Questions or improvements? Email us at support@deployhq.com or reach out on @deployhq.