Skip to main content
  1. Languages/
  2. PHP Guides/

Modernizing WordPress: Building Custom Plugins with PHP 8.x and Composer

Jeff Taakey
Author
Jeff Taakey
21+ Year CTO & Multi-Cloud Architect.

Introduction
#

Let’s be honest: WordPress development has a reputation. For years, “WordPress PHP” was synonymous with massive procedural files, global variables, and a complete disregard for software architecture. But as we settle into 2025, that narrative has shifted dramatically.

With PHP 8.3 and 8.4 becoming the standard on managed hosting environments, WordPress is no longer just a CMS for blogs; it is a robust application framework. However, the core software still maintains backward compatibility with ancient code, which means it’s up to us—the developers—to enforce modern standards.

If you are a mid-to-senior PHP developer coming from a Laravel or Symfony background, looking at a typical functions.php file probably hurts your eyes. This article is your bridge. We are going to build a custom WordPress plugin using strict typing, PSR-4 autoloading via Composer, and clear object-oriented architecture.

By the end of this guide, you will have a production-ready boilerplate that brings the joy of modern PHP back into the WordPress ecosystem.


Prerequisites and Environment
#

Before we write a single line of code, we need to ensure our tooling is ready for professional development. We are moving away from “Cowboy Coding” (editing files directly on the server) to a structured local workflow.

Requirements for 2025
#

  1. PHP 8.2 or higher: We will utilize features like readonly classes, constructor property promotion, and intersection types.
  2. Composer: Essential for dependency management and autoloading.
  3. Local Development Environment: Tools like DDEV, Lando, or LocalWP.
  4. Node.js & NPM (Optional): If you plan to bundle modern JavaScript (React blocks), though this guide focuses on the PHP side.

The “Modern” vs. “Legacy” Mindset
#

To understand why we are doing this, let’s look at the differences between how plugins were written in 2015 versus how they should be written today.

Feature Legacy Approach (The “Old Way”) Modern Approach (The “Right Way”)
File Loading require_once everywhere Composer PSR-4 Autoloading
Code Structure Massive procedural functions Object-Oriented (Single Responsibility)
State Management Global variables (global $wpdb) Dependency Injection
Type Safety Loose types, defensive coding Strict Typing (declare(strict_types=1))
Testing Manual browser refreshing Automated Unit/Integration Tests (Pest/PHPUnit)

Step 1: Directory Structure and Scaffolding
#

A clean directory structure is the foundation of a maintainable plugin. We will separate our logic (src) from our entry point and assets.

Create a new folder for your plugin (e.g., pdp-modern-standards) inside your wp-content/plugins directory.

mkdir pdp-modern-standards
cd pdp-modern-standards
mkdir src assets tests
touch pdp-modern-standards.php

The Architecture Plan
#

Before coding, let’s visualize how WordPress will interact with our modern codebase. We want to isolate WordPress hooks from our business logic as much as possible.

graph TD WP[WordPress Core] -->|Loads| Entry[Plugin Entry File] Entry -->|Initializes| Composer[Composer Autoloader] Composer -->|Loads Classes| App[Main Plugin Class] subgraph "Modern PHP Domain" App -->|Bootstraps| Container[DI Container / Services] Container -->|Instantiates| CPT[Post Type Registrar] Container -->|Instantiates| API[REST API Controller] Container -->|Instantiates| Assets[Asset Manager] end CPT -.->|Hooks into| WP API -.->|Hooks into| WP Assets -.->|Hooks into| WP style WP fill:#21759b,stroke:#fff,color:#fff style Entry fill:#f39c12,stroke:#fff,color:#fff style App fill:#27ae60,stroke:#fff,color:#fff

Step 2: Initializing Composer
#

The most significant upgrade you can make to a WordPress plugin is using Composer. It handles class loading so you never have to write a require statement for your own classes again.

Run composer init in your terminal or create a composer.json file manually:

{
    "name": "phpdevpro/modern-standards",
    "description": "A WordPress plugin demonstrating modern PHP standards.",
    "type": "wordpress-plugin",
    "license": "GPL-2.0-or-later",
    "authors": [
        {
            "name": "PHP DevPro Team",
            "email": "[email protected]"
        }
    ],
    "require": {
        "php": ">=8.2"
    },
    "require-dev": {
        "squizlabs/php_codesniffer": "^3.7",
        "phpcompatibility/php-compatibility": "^9.3"
    },
    "autoload": {
        "psr-4": {
            "PHPDevPro\\Modern\\": "src/"
        }
    },
    "config": {
        "optimize-autoloader": true,
        "sort-packages": true
    }
}

Key Takeaway: The autoload section maps the namespace PHPDevPro\Modern\ to the src/ directory.

Now, generate the autoloader:

composer install

Step 3: The Plugin Entry Point
#

This is the only file WordPress strictly requires. Its job is solely to define plugin metadata, load Composer, and boot the application. Do not put business logic here.

File: pdp-modern-standards.php

<?php
/**
 * Plugin Name:       PDP Modern Standards
 * Plugin URI:        https://phpdevpro.com
 * Description:       A demonstration of modern PHP architecture in WordPress.
 * Version:           1.0.0
 * Requires at least: 6.4
 * Requires PHP:      8.2
 * Author:            PHP DevPro Team
 * License:           GPL v2 or later
 * Text Domain:       pdp-modern
 */

declare(strict_types=1);

use PHPDevPro\Modern\Plugin;

// 1. Security Check
if (!defined('ABSPATH')) {
    exit;
}

// 2. Load Composer Autoloader
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
    require __DIR__ . '/vendor/autoload.php';
} else {
    // Graceful fallback if user forgot to run composer install
    add_action('admin_notices', function() {
        echo '<div class="error"><p>PHP DevPro Plugin: Please run "composer install".</p></div>';
    });
    return;
}

// 3. Boot the Plugin
// We use a singleton pattern here specifically because WP plugins
// are inherently global singletons within the WP lifecycle.
function pdp_modern_init(): void {
    $plugin = new Plugin(__FILE__);
    $plugin->boot();
}

add_action('plugins_loaded', 'pdp_modern_init');

Step 4: The Core Plugin Class
#

Now we move into the src directory. The Plugin class acts as our orchestrator. It manages the lifecycle of the plugin and registers services.

File: src/Plugin.php

<?php

namespace PHPDevPro\Modern;

use PHPDevPro\Modern\Services\PostTypeRegistrar;
use PHPDevPro\Modern\Services\AssetLoader;

class Plugin
{
    /**
     * @var array<int, string>
     */
    private array $services = [
        PostTypeRegistrar::class,
        AssetLoader::class,
    ];

    public function __construct(
        public readonly string $pluginFilePath
    ) {}

    public function boot(): void
    {
        foreach ($this->services as $serviceClass) {
            if (class_exists($serviceClass)) {
                $service = new $serviceClass($this);
                if (method_exists($service, 'register')) {
                    $service->register();
                }
            }
        }
    }

    public function getUrl(string $path = ''): string
    {
        return plugins_url($path, $this->pluginFilePath);
    }

    public function getPath(string $path = ''): string
    {
        return plugin_dir_path($this->pluginFilePath) . $path;
    }
}

Analysis
#

We are using Constructor Property Promotion (public readonly string $pluginFilePath) to reduce boilerplate. The boot method iterates through a list of service classes and instantiates them. This is a simplified Service Provider pattern.


Step 5: Creating Services (The “Meat”)
#

In modern WordPress development, we organize code by “Services” or “Domains.” Let’s create a service responsible for registering a “Portfolio” Custom Post Type (CPT).

Create directory: src/Services/

File: src/Services/PostTypeRegistrar.php

<?php

namespace PHPDevPro\Modern\Services;

use PHPDevPro\Modern\Plugin;

class PostTypeRegistrar
{
    public function __construct(
        private readonly Plugin $plugin
    ) {}

    public function register(): void
    {
        add_action('init', [$this, 'registerPortfolioType']);
    }

    public function registerPortfolioType(): void
    {
        $labels = [
            'name'                  => _x('Portfolios', 'Post Type General Name', 'pdp-modern'),
            'singular_name'         => _x('Portfolio', 'Post Type Singular Name', 'pdp-modern'),
            'menu_name'             => __('Portfolios', 'pdp-modern'),
            'all_items'             => __('All Portfolios', 'pdp-modern'),
            'add_new_item'          => __('Add New Portfolio', 'pdp-modern'),
        ];

        $args = [
            'label'                 => __('Portfolio', 'pdp-modern'),
            'labels'                => $labels,
            'supports'              => ['title', 'editor', 'thumbnail', 'custom-fields'],
            'hierarchical'          => false,
            'public'                => true,
            'show_ui'               => true,
            'show_in_menu'          => true,
            'show_in_rest'          => true, // Essential for Gutenberg
            'menu_icon'             => 'dashicons-art',
            'has_archive'           => true,
            'rewrite'               => ['slug' => 'portfolio'],
        ];

        register_post_type('pdp_portfolio', $args);
    }
}

Why use a class for a simple CPT?
#

  1. Isolation: The logic is encapsulated.
  2. Context: We have access to the main $plugin instance if we need paths or configuration.
  3. Testing: We can test the registerPortfolioType method independently.

Step 6: Handling Assets
#

Let’s quickly add a second service to show how easy it is to scale this architecture.

File: src/Services/AssetLoader.php

<?php

namespace PHPDevPro\Modern\Services;

use PHPDevPro\Modern\Plugin;

class AssetLoader
{
    public function __construct(
        private readonly Plugin $plugin
    ) {}

    public function register(): void
    {
        add_action('wp_enqueue_scripts', [$this, 'enqueueFrontend']);
        add_action('admin_enqueue_scripts', [$this, 'enqueueAdmin']);
    }

    public function enqueueFrontend(): void
    {
        wp_enqueue_style(
            'pdp-modern-frontend',
            $this->plugin->getUrl('assets/css/frontend.css'),
            [],
            '1.0.0'
        );
    }

    public function enqueueAdmin(): void
    {
        // Only load on our specific post type to improve admin performance
        $screen = get_current_screen();
        if ($screen && $screen->id === 'pdp_portfolio') {
             // Logic to load admin CSS/JS
        }
    }
}

Step 7: Performance and Security Best Practices
#

When building plugins for high-traffic sites (which you should always assume your plugin will run on), performance and security are non-negotiable.

1. Database Abstraction & Security
#

Never use $_POST or $_GET directly without sanitization. Use WordPress helper functions, but type-hint them.

// BAD
$id = $_GET['id'];

// GOOD
$id = isset($_GET['id']) ? absint($_GET['id']) : 0;

When querying the database, always use $wpdb->prepare:

global $wpdb;
$table = $wpdb->prefix . 'my_table';
// Secure query using placeholders
$results = $wpdb->get_results(
    $wpdb->prepare("SELECT * FROM $table WHERE status = %s", 'active')
);

2. Capabilities Checks
#

Always check permissions before performing actions.

if (!current_user_can('manage_options')) {
    return;
}

3. Nonces (Number used ONCE)
#

If you are building forms or AJAX handlers, verify the nonce to prevent CSRF (Cross-Site Request Forgery) attacks.

if (!isset($_POST['_wpnonce']) || !wp_verify_nonce($_POST['_wpnonce'], 'pdp_action')) {
    wp_send_json_error(['message' => 'Invalid nonce'], 403);
}

4. Avoiding “Hook Hell”
#

One common pitfall in WP development is anonymous functions (Closures) in hooks. Avoid this:

add_action('init', function() { ... });

Why? You cannot unhook anonymous functions later. If a child theme or another developer wants to modify your plugin’s behavior, they are stuck. Always use [$this, 'methodName'] or a named function.


Conclusion
#

We have successfully transformed the chaotic nature of standard WordPress development into a structured, modern PHP application.

By adopting this architecture, you gain:

  1. Readability: New developers can find exactly where the “Portfolio” logic lives immediately.
  2. Scalability: Adding a new feature is as simple as creating a new Service class and adding it to the Plugin::$services array.
  3. Professionalism: Your code is now testable, strictly typed, and PSR-4 compliant.

Next Steps for the Pro Developer
#

  • Unit Testing: Integrate Pest PHP to write tests for your service classes.
  • Dependency Injection Container: For larger plugins, replace our simple array loop with a real DI container like PHP-DI or League/Container to handle complex dependencies.
  • CI/CD: Set up GitHub Actions to run phpcs (PHP CodeSniffer) using the WordPress Coding Standards ruleset on every push.

WordPress doesn’t have to be “legacy code.” With the tools available in 2026, it is a powerful platform capable of hosting enterprise-grade software—if you build it right.

Happy coding!