Home / Blog / Modern WordPress plugin development: PSR-4, Composer, autoload

Modern WordPress plugin development: PSR-4, Composer, autoload

WordPress plugin development should move to modern PHP standards. PSR-4, Composer, namespaces: a practical guide.

The old style of WordPress plugin development: a single .php file, hundreds of functions, wp_xxx_ prefix naming conventions. In 2025, that approach isn’t sustainable.

I’ve got 19 years of WP development behind me. For the last five, I’ve built every plugin around modern PHP standards. Here are the patterns I use.

Why the modern approach?

The old plugin pattern (single file, function prefixes):

Problems:
– Code organization is painful, everything is a global function
– No easy use of classes, interfaces, traits (or loading is manual)
– Dependency management is primitive
– Unit testing is a challenge
– IDE autocomplete is limited
– Team development is conflict-prone

Modern PHP standards (PSR-4, Composer, namespaces) fix all of this.

Basic setup

Base structure for a new plugin:

my-awesome-plugin/
├── my-awesome-plugin.php    # Main file
├── composer.json             # Dependencies
├── vendor/                   # Composer autoload
├── src/                      # PSR-4 classes
│   ├── Plugin.php
│   ├── Admin/
│   │   └── SettingsPage.php
│   ├── Frontend/
│   │   └── Renderer.php
│   └── Services/
│       └── ApiClient.php
├── templates/                # PHP templates
├── assets/                   # CSS, JS, images
├── tests/                    # Unit tests
└── languages/                # i18n

Main plugin file

<?php
/**
 * Plugin Name: My Awesome Plugin
 * Version: 1.0.0
 * Author: Ali Çınaroğlu
 * Text Domain: my-awesome-plugin
 */

if (!defined('ABSPATH')) exit;

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

use MyPluginPlugin;

Plugin::boot(__FILE__);

The main file is minimal. It defines plugin constants, loads the autoloader, and boots the Plugin class.

Composer setup

composer.json:

{
    "name": "alicinaroglu/my-awesome-plugin",
    "description": "My awesome WordPress plugin",
    "type": "wordpress-plugin",
    "require": {
        "php": ">=7.4"
    },
    "require-dev": {
        "phpunit/phpunit": "^9.0",
        "wp-phpunit/wp-phpunit": "^6.0"
    },
    "autoload": {
        "psr-4": {
            "MyPlugin\": "src/"
        }
    }
}

composer install creates vendor/ and autoload is ready.

Plugin class pattern

namespace MyPlugin;

class Plugin {
    private static ?Plugin $instance = null;
    
    public static function boot(string $mainFile): void {
        if (self::$instance === null) {
            self::$instance = new self($mainFile);
            self::$instance->init();
        }
    }
    
    private function __construct(private string $mainFile) {}
    
    private function init(): void {
        add_action('init', [$this, 'onInit']);
        add_action('admin_menu', [new AdminSettingsPage(), 'register']);
        add_action('wp_enqueue_scripts', [new FrontendAssets(), 'enqueue']);
    }
    
    public function onInit(): void {
        // Plugin boot logic
    }
}

Singleton pattern, dependency injection, clear entry points.

Namespace discipline

Every class lives in its own namespace:

// src/Admin/SettingsPage.php
namespace MyPluginAdmin;

class SettingsPage {
    public function register(): void { /* ... */ }
}

// src/Services/ApiClient.php
namespace MyPluginServices;

class ApiClient {
    public function fetch(string $url): array { /* ... */ }
}

No namespace collisions, IDE autocomplete actually works.

Dependency injection

A simple DI pattern:

namespace MyPluginServices;

class ApiClient {
    public function __construct(
        private string $apiKey,
        private LoggerInterface $logger
    ) {}
    
    public function fetch(string $endpoint): array {
        $this->logger->info("Fetching: $endpoint");
        // ... HTTP call ...
    }
}

// Usage
$client = new ApiClient(
    apiKey: get_option('my_api_key'),
    logger: new FileLogger(WP_CONTENT_DIR . '/my-plugin.log')
);

Dependencies go through the constructor. Easy to test (mock the dependencies).

Service container (optional)

For larger plugins, a service container:

use DIContainer;

$container = new Container();

$container->set(LoggerInterface::class, DIcreate(FileLogger::class));
$container->set(ApiClient::class, DIautowire());

// Usage
$client = $container->get(ApiClient::class);  // Auto-resolved dependencies

PHP-DI, Pimple, Symfony Container. Overkill for small plugins.

Hook registration

The old pattern:

add_action('init', 'my_plugin_init');
function my_plugin_init() { /* ... */ }

The modern pattern:

namespace MyPluginHooks;

class Initializer {
    public function __construct(private ApiClient $client) {}
    
    public function register(): void {
        add_action('init', [$this, 'onInit']);
    }
    
    public function onInit(): void {
        $this->client->setup();
    }
}

// At plugin boot
(new Initializer($apiClient))->register();

Class methods attach to hooks. Testable, organized.

Database schema management

Create tables on plugin activation:

namespace MyPluginDatabase;

class Schema {
    public static function install(): void {
        global $wpdb;
        
        $charset = $wpdb->get_charset_collate();
        
        $sql = "CREATE TABLE {$wpdb->prefix}my_plugin_data (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            user_id BIGINT UNSIGNED NOT NULL,
            data LONGTEXT,
            created_at DATETIME NOT NULL,
            PRIMARY KEY (id),
            INDEX idx_user_id (user_id)
        ) $charset;";
        
        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta($sql);
    }
}

register_activation_hook(__FILE__, [Schema::class, 'install']);

For migrations: versioning plus dbDelta. Schema evolution stays manageable.

Using the Settings API

namespace MyPluginAdmin;

class Settings {
    private const OPTION_NAME = 'my_plugin_settings';
    
    public function register(): void {
        add_action('admin_init', [$this, 'registerSettings']);
    }
    
    public function registerSettings(): void {
        register_setting('my_plugin_group', self::OPTION_NAME);
        
        add_settings_section(
            'general_section',
            __('General Settings', 'my-awesome-plugin'),
            null,
            'my_plugin_settings'
        );
        
        add_settings_field(
            'api_key',
            __('API Key', 'my-awesome-plugin'),
            [$this, 'renderApiKeyField'],
            'my_plugin_settings',
            'general_section'
        );
    }
    
    public function renderApiKeyField(): void {
        $settings = get_option(self::OPTION_NAME, []);
        $value = esc_attr($settings['api_key'] ?? '');
        echo "<input type='text' name="" . self::OPTION_NAME . "[api_key]" value="$value">";
    }
}

REST API endpoints

Modern WP plugins usually expose a REST API:

namespace MyPluginApi;

class RestController {
    public function register(): void {
        add_action('rest_api_init', [$this, 'registerRoutes']);
    }
    
    public function registerRoutes(): void {
        register_rest_route('my-plugin/v1', '/data/(?P<id>d+)', [
            'methods' => 'GET',
            'callback' => [$this, 'getData'],
            'permission_callback' => [$this, 'canAccess'],
            'args' => [
                'id' => [
                    'required' => true,
                    'validate_callback' => fn($v) => is_numeric($v)
                ]
            ]
        ]);
    }
    
    public function getData(WP_REST_Request $request): WP_REST_Response {
        $id = $request['id'];
        return new WP_REST_Response(['data' => $data], 200);
    }
    
    public function canAccess(): bool {
        return current_user_can('manage_options');
    }
}

Testing

PHPUnit setup:

// tests/bootstrap.php
require_once __DIR__ . '/../vendor/autoload.php';
require_once WP_PHPUNIT_DIR . '/wordpress/wp-load.php';

// tests/Unit/ApiClientTest.php
use PHPUnitFrameworkTestCase;
use MyPluginServicesApiClient;

class ApiClientTest extends TestCase {
    public function testFetch(): void {
        $client = new ApiClient('test_key', $this->createMock(LoggerInterface::class));
        $result = $client->fetch('/endpoint');
        $this->assertIsArray($result);
    }
}
./vendor/bin/phpunit tests/

Security basics

In every plugin:

  • Nonce verification: on form submissions
  • Capability check: on admin actions
  • Data sanitization: on user input
  • Output escaping: on HTML output
  • Prepared statements: on DB queries

Those five disciplines are the foundation of security.

i18n (localization)

__('Save Changes', 'my-awesome-plugin')
_e('Saved', 'my-awesome-plugin')
_n('item', 'items', $count, 'my-awesome-plugin')

The text domain equals the plugin slug. Call load_plugin_textdomain() inside the init action.

Build and distribution

Dev vs production:

  • Dev: composer install with dev dependencies
  • Production: composer install --no-dev --optimize-autoloader

A build script (Gulp, Webpack) compiles and minifies assets. The release zip doesn’t contain dev files.

Wrap-up

WordPress plugin development gets dramatically more maintainable once you adopt modern PHP standards. PSR-4 autoload, namespaces, dependency injection, testing.

Setting up this discipline on the first plugin takes a day or two. After that, every plugin is generated from a template. It scales well.

When retrofitting old plugins, do incremental migration. New features follow the modern pattern, legacy code gets refactored over time.

Have a project on this topic?

Leave a brief summary — I’ll get back to you within 24 hours.

Get in touch