paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

January 28, 2022

PHP for WordPress Engineering: What Every Developer Should Know Before Building Production Projects

From language fundamentals to advanced patterns - the PHP knowledge that separates WordPress engineers from WordPress users

Introduction

WordPress is a PHP application, which sounds obvious until you watch engineers try to work on it without actually knowing PHP - relying instead on copy-pasted snippets, page-builder plugins, and tutorial code that happens to work without anyone understanding why. This gets you surprisingly far in WordPress, which is precisely the problem: the platform is forgiving enough that weak PHP fundamentals don't show up immediately, they show up six months later as a security incident, a performance cliff, or a codebase nobody on the team can safely modify.

This article is for engineers who want to close that gap. It assumes you can write code, but may not know which parts of PHP actually matter for WordPress work, or how WordPress's own conventions - procedural-style hooks sitting alongside object-oriented plugin classes, loose typing colliding with a huge global function library - differ from the PHP you'd write for a greenfield Laravel or Symfony project. We'll move from the language fundamentals that WordPress leans on hardest, through the object-oriented patterns that scale a codebase past a few hundred lines, into performance and security practices that matter specifically because of how WordPress is architected, and close with five projects that build this knowledge progressively.

Why PHP Fundamentals Matter More in WordPress, Not Less

It's tempting to assume that because WordPress abstracts so much - the hook system, WP_Query, the Settings API - an engineer can get away with a thin understanding of PHP itself. The opposite is true. WordPress's core and the vast majority of its plugin ecosystem were written across more than two decades, spanning PHP 4 through PHP 8.x, and that history is still visible in the codebase: procedural functions calling into object-oriented classes, loose comparison operators used where strict ones were needed, and global variables ($post, $wpdb, $wp_query) that a function anywhere in the call stack can read or mutate. An engineer who doesn't understand PHP's type coercion rules, variable scoping, or reference semantics will misdiagnose bugs in this environment constantly, because the bug is often not in WordPress's API but in how PHP itself behaves under the hood.

There's also a practical reality: WordPress plugins and themes are not sandboxed from each other. A plugin's PHP code runs in the same process, with the same global namespace (absent proper namespacing), as every other active plugin and the theme. This means PHP-level discipline - avoiding global variable pollution, respecting function and class naming conventions, understanding autoloading - isn't a nice-to-have, it's what keeps one plugin from silently breaking another. Engineers coming from more isolated environments, such as containerized microservices where each service owns its own process, often underestimate how much a single careless global $foo; statement can cascade into unrelated parts of a WordPress site.

Finally, PHP itself has changed substantially since WordPress's early years. PHP 8 introduced union types, named arguments, the nullsafe operator, and JIT compilation, among other features, and WordPress core has gradually adopted compatibility with these versions while trying not to break a long tail of older plugins. An engineer who knows modern PHP can write faster, more defensible code than the WordPress ecosystem's historical average - but only if they understand where WordPress's own minimum PHP version requirements and backward-compatibility commitments constrain which modern features are safe to use on a given project.

Core PHP Concepts Every WordPress Engineer Needs

Types, Comparisons, and WordPress's Loose-Typing Traps

PHP is dynamically typed with optional type declarations, and WordPress's own codebase is written largely without strict types, which means engineers must understand PHP's type juggling rules to avoid subtle bugs. The classic trap is == versus ===: comparing a string returned from get_post_meta() against an integer, or checking a hook's return value with loose equality, can silently produce wrong behavior because PHP coerces types during loose comparisons.

<?php
// A real class of bug in WordPress code: loose comparison against meta values.
$featured = get_post_meta( $post_id, 'is_featured', true ); // Always returns a string or ''.

// Buggy: '0' == false is true, but so is '' == false and '0' == 0 - easy to get wrong.
if ( $featured == true ) {
    // This runs even when $featured is the string '0', which is often NOT intended.
}

// Correct: compare against the actual expected representation.
if ( $featured === '1' ) {
    // Only true when the meta value is literally the string '1'.
}

Declaring declare(strict_types=1); at the top of your own plugin files, and typing function signatures explicitly, is one of the highest-leverage habits an engineer can adopt in a WordPress codebase, even though WordPress core itself does not do this consistently. It will not retroactively fix WordPress's own loosely-typed APIs, but it stops your own code from compounding the problem, and it makes static analysis tools like PHPStan or Psalm dramatically more useful.

Arrays, Closures, and the Functional Habits WordPress Rewards

WordPress's APIs are heavily array-based - WP_Query arguments, register_post_type() configuration, REST API responses - which means fluency with PHP's array functions (array_map, array_filter, array_reduce, array_merge versus the + operator for arrays) pays off constantly. Closures, introduced in PHP 5.3 and refined with arrow functions (fn() => ...) in PHP 7.4, are also central to modern WordPress code, since add_action() and add_filter() both accept closures as callbacks, letting engineers avoid polluting the global function namespace with single-use callback functions.

<?php
// Combining array functions with closures for a realistic WordPress data transform.
function myapp_get_published_titles( array $post_ids ): array {
    $posts = array_filter(
        array_map( 'get_post', $post_ids ),
        fn( $post ) => $post instanceof WP_Post && $post->post_status === 'publish'
    );

    return array_values(
        array_map( fn( WP_Post $post ): string => $post->post_title, $posts )
    );
}

Understanding the distinction between array_merge() and the + array union operator is a small but recurring source of confusion: array_merge() re-indexes numeric keys and lets later values overwrite earlier ones on string keys, while + preserves the first array's values on key collisions and keeps original numeric keys. This matters directly in WordPress, where merging user-supplied arguments with defaults - a pattern seen in nearly every wp_parse_args() call - depends on predictable array-merging behavior.

Object-Oriented PHP and Namespacing in a Procedural World

WordPress core predates widespread PHP namespace support (namespaces arrived in PHP 5.3, after much of WordPress's core API was already established), so core still uses global function and class names, often prefixed manually (WP_Query, wp_insert_post) to avoid collisions. Your own plugin code has no such excuse: using PHP namespaces and PSR-4 autoloading via Composer is the standard professional practice, and it's the difference between a plugin that can safely coexist with dozens of others and one that silently breaks when two plugins happen to define the same function name.

<?php
namespace MyApp\PostTypes;

class EventPostType {
    private const POST_TYPE = 'event';

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

    public function registerPostType(): void {
        register_post_type( self::POST_TYPE, [
            'labels'       => [ 'name' => __( 'Events', 'myapp' ) ],
            'public'       => true,
            'show_in_rest' => true,
            'supports'     => [ 'title', 'editor', 'custom-fields' ],
        ] );
    }
}

// Bootstrap file, loaded via Composer's autoloader.
( new \MyApp\PostTypes\EventPostType() )->register();

Implementation: PHP Patterns in Real WordPress Code

Beyond individual language features, certain structural patterns recur across well-engineered WordPress projects, and recognizing them lets you read and write WordPress code the way experienced plugin authors do. One of the most common is the singleton bootstrap pattern for a plugin's main class, which ensures a plugin initializes exactly once regardless of how many times its file is included - a real risk in WordPress, where plugin and theme files can sometimes be loaded more than once depending on server configuration or other plugins' behavior.

<?php
namespace MyApp;

final class Plugin {
    private static ?Plugin $instance = null;

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

    private function __construct() {
        add_action( 'plugins_loaded', [ $this, 'bootstrap' ] );
    }

    public function bootstrap(): void {
        ( new PostTypes\EventPostType() )->register();
        ( new Rest\EventsController() )->registerRoutes();
    }
}

Plugin::instance();

A second pattern worth internalizing is wrapping $wpdb access behind a repository class rather than scattering raw SQL across hook callbacks. WordPress's $wpdb object provides prepare() for parameterized queries, and every raw query touching user input must pass through it to avoid SQL injection - but centralizing that access in one class also makes the data layer testable and gives you a single place to add caching.

<?php
namespace MyApp\Repositories;

class EventRegistrationRepository {
    public function __construct( private readonly \wpdb $db ) {}

    public function countForEvent( int $eventId ): int {
        $table = $this->db->prefix . 'event_registrations';

        return (int) $this->db->get_var(
            $this->db->prepare(
                "SELECT COUNT(*) FROM {$table} WHERE event_id = %d AND status = %s",
                $eventId,
                'confirmed'
            )
        );
    }

    public function insert( int $eventId, int $userId ): int|false {
        $table = $this->db->prefix . 'event_registrations';

        $result = $this->db->insert( $table, [
            'event_id'   => $eventId,
            'user_id'    => $userId,
            'status'     => 'confirmed',
            'created_at' => current_time( 'mysql' ),
        ], [ '%d', '%d', '%s', '%s' ] );

        return $result ? $this->db->insert_id : false;
    }
}

A third pattern is dependency injection via a minimal container or simple constructor wiring, rather than instantiating dependencies inline inside every class. WordPress doesn't ship a DI container, but nothing stops a plugin from using one (PHP-DI is a popular, well-maintained choice for standalone PHP projects and works fine inside a WordPress plugin). Even without a full container, wiring dependencies explicitly in a single bootstrap file - as shown in the EventPostType and repository examples above - keeps classes testable in isolation, since you can pass in a mock $wpdb-like object during unit tests instead of hitting a real database.

Running static analysis against this kind of code closes the loop. PHPStan, configured with the szepeviktor/phpstan-wordpress extension that teaches it WordPress's core function signatures, catches type errors, undefined function calls, and dead code before they reach production:

# Install PHPStan with WordPress stub support via Composer
composer require --dev phpstan/phpstan szepeviktor/phpstan-wordpress

# Run analysis at a strict level against your plugin's source
./vendor/bin/phpstan analyse src/ --level=6

Trade-offs and Pitfalls Specific to PHP in WordPress

Not every modern PHP practice transfers cleanly into WordPress, and pretending otherwise causes its own problems. Strict typing is a good example: declaring strict_types=1 in your own files is safe, but you cannot force WordPress core or third-party plugins to honor strict types, which means your strictly-typed function might still receive a loosely-typed value from a hook callback elsewhere in the system. Engineers who over-trust type declarations in a mixed codebase sometimes get surprised when a filter callback from another plugin passes a string where an int was expected, triggering a TypeError that crashes the site rather than degrading gracefully - which is itself an argument for validating hook inputs defensively, even when your own code is strictly typed.

Composer dependency management is another area with real friction. A plugin built with Composer-managed dependencies (an HTTP client, a logging library, a utility package) must either bundle those dependencies inside the plugin or risk version conflicts with another plugin on the same site that depends on a different version of the same package - a classic "dependency hell" problem made worse because WordPress plugins don't run in isolated processes. Tools like Mozart (which rewrites a dependency's namespace and bundles it directly into your plugin) exist specifically to solve this, by prefixing third-party package namespaces so two plugins can each bundle different versions of the same library without colliding.

Performance is a third area where PHP-level decisions matter more in WordPress than developers expect. Because a typical WordPress request runs through PHP on every single page load (absent full-page caching), inefficient PHP - unnecessary object instantiation inside loops, repeated expensive function calls instead of caching their results, deeply nested array_merge() calls inside WP_Query filters - compounds across every hook that core or other plugins execute during the request lifecycle. Profiling tools like Query Monitor (a WordPress plugin, not a generic PHP profiler) or Xdebug's profiler are essential for finding where PHP time is actually going in a slow WordPress request, because the bottleneck is frequently not the database but PHP itself re-computing something on every hook call that could have been computed once and cached.

Security deserves its own callout, since PHP's permissiveness is a double-edged sword here. WordPress provides escaping functions (esc_html(), esc_attr(), esc_url(), esc_js()) and sanitization functions (sanitize_text_field(), sanitize_email()) specifically because raw PHP string interpolation into HTML or SQL is unsafe by default. An engineer who understands PHP but not WordPress's security conventions will sometimes "simplify" code by removing what looks like redundant escaping, not realizing that every point of output needs context-appropriate escaping independently - escaping input on the way in does not make it safe to skip escaping on the way out.

Best Practices for Writing PHP in a WordPress Codebase

Adopt a consistent coding standard and enforce it automatically rather than through code review alone. The WordPress Coding Standards (WPCS), run through PHP_CodeSniffer, catch both stylistic inconsistencies and real security issues, such as unescaped output or unsanitized input, directly in CI. Pairing WPCS with PHPStan gives you two complementary layers: WPCS enforces WordPress-specific conventions, while PHPStan catches general PHP type and logic errors that WPCS doesn't look for.

# Install and run WordPress Coding Standards via Composer and PHP_CodeSniffer.
composer require --dev wp-coding-standards/wpcs dealerdirect/phpcodesniffer-composer-installer
./vendor/bin/phpcs --standard=WordPress src/

# Auto-fix what can be safely auto-fixed.
./vendor/bin/phpcbf --standard=WordPress src/

Write unit and integration tests at the layer where they're cheapest to maintain. Pure PHP logic - a repository class, a data transformer, a pricing calculation - should be unit tested with PHPUnit and, where WordPress functions would otherwise need to be bootstrapped, mocked using a library like Brain Monkey or by injecting WordPress dependencies (such as $wpdb) rather than calling global functions directly inside testable classes. Code that genuinely needs WordPress's runtime - hook registration, WP_Query behavior, REST route registration - is better covered by WordPress's own integration test scaffolding (WP_UnitTestCase), which boots a real, temporary WordPress installation.

Keep PHP version compatibility deliberate rather than accidental. Declare a minimum supported PHP version in your plugin header and in Composer's require section, and test against it in CI using a matrix of PHP versions, since a feature like named arguments or enums (PHP 8.1) will fail outright on older PHP installations still common in shared hosting environments. GitHub Actions and similar CI platforms make it straightforward to run a plugin's test suite against several PHP versions in parallel, catching compatibility regressions before a release rather than after a support ticket.

Finally, treat escaping and sanitization as a mechanical habit rather than a judgment call made case by case. A useful mental model: sanitize on the way in, escape on the way out, every time, with no exceptions for "trusted" data, because what counts as trusted tends to change as a codebase grows and more code paths feed into the same function.

Analogies and Mental Models

It helps to think of WordPress's relationship to PHP the way you'd think of a large, old city built without a master plan: PHP is the physical material - steel, concrete, wiring - and WordPress is centuries of buildings constructed on top of it by different builders, with different standards, in different eras. You can't tear the city down and start over, so professional work means understanding the material well enough to build a modern, code-compliant structure next to buildings that predate the current building code, without your structure depending on theirs staying exactly as they are.

Another useful model is thinking of the global PHP process WordPress runs in as a shared kitchen rather than a private one. Every plugin is a different cook using the same countertops, the same knives, and the same oven at the same time. PHP's namespacing, autoloading, and WordPress's hook system are the kitchen's rules for not stepping on each other's dishes - ignore them, and your dish might be fine in isolation but ruin everyone else's when run together.

80/20 Insight

A small set of PHP competencies accounts for most of the difference between fragile and professional WordPress code. Strict comparisons and explicit type handling eliminate a large share of subtle logic bugs. Namespacing and Composer-based autoloading eliminate most naming collisions and make dependency management sane. Centralizing $wpdb access behind a repository layer, with every query passed through prepare(), eliminates the majority of SQL injection risk in custom code. Consistent use of WordPress's escaping functions at every output point eliminates most cross-site scripting risk. And running PHPStan with WordPress stubs in CI catches a large fraction of the remaining errors before they reach production. None of these require exotic frameworks or deep architectural ambition - they are PHP fundamentals applied consistently, and consistency is the actual hard part.

Five Practice Projects: Basic to Advanced

1. A Custom Post Type Plugin Using Only Procedural PHP (Beginner)

Build a simple plugin registering a custom post type and a few meta fields, written in plain procedural PHP without classes or namespaces, intentionally mirroring WordPress core's own style. This forces you to get comfortable with PHP arrays, closures as hook callbacks, and WordPress's naming conventions before adding object-oriented structure on top.

2. The Same Plugin, Refactored into Namespaced Classes with Composer (Beginner-Intermediate)

Rebuild project one using PHP namespaces, PSR-4 autoloading via Composer, and a small set of classes (a post-type registrar, a meta-box handler). This project is specifically about experiencing the before-and-after of introducing structure, and it's where most engineers first feel the practical benefit of autoloading over manual require_once chains.

3. A Data Repository Backed by a Custom Table (Intermediate)

Create a plugin with a custom database table (created via dbDelta() on activation), a repository class wrapping all $wpdb access with prepared statements, and a REST endpoint exposing the data. This project directly exercises SQL injection prevention, array-to-SQL mapping, and the gap between WordPress's ORM-like conveniences (WP_Query) and raw $wpdb usage for custom schemas.

4. A Fully Tested Plugin with CI (Advanced)

Take project three and add a complete testing and quality pipeline: PHPUnit tests for the repository layer using a mocked $wpdb, WP_UnitTestCase integration tests for the REST endpoint, PHPStan at a strict level with WordPress stubs, WPCS linting, and a GitHub Actions workflow running all of it against a matrix of PHP versions. This project is less about new PHP features and more about operationalizing everything from the previous three projects into a repeatable, automated workflow.

5. A Multi-Plugin System with Shared, Namespaced Dependencies (Expert)

Build two separate plugins that both depend on a shared Composer package (for example, a small internal SDK for calling an external API), using a tool like Mozart to prefix and bundle the dependency separately inside each plugin so their versions can diverge safely. This project surfaces the hardest PHP-in-WordPress problem - dependency isolation without process isolation - and requires understanding Composer's autoloader internals well enough to debug collisions when they occur.

Key Takeaways

Conclusion

PHP competence is not a side skill for WordPress engineering - it is the foundation everything else sits on. WordPress's hook system, REST API, and block editor are all interesting architectural layers, but they are built in PHP, run on PHP's execution model, and inherit PHP's type system and its sharp edges. Engineers who treat WordPress as a black box to be configured through plugins eventually hit a wall, because the platform's flexibility is really an invitation to write real PHP code, and the quality of that code determines whether a project scales gracefully or accumulates the kind of technical debt that makes every future change risky.

The path from basic to advanced here isn't about learning exotic PHP features - it's about applying ordinary, well-understood PHP discipline (strict typing, namespacing, tested and statically analyzed code, careful escaping) consistently inside an ecosystem that has historically not required it. That gap between what's possible and what's typical is exactly where a professional WordPress engineer creates value, and the five projects above are designed to build that discipline step by step, from a single procedural plugin to a dependency-isolated multi-plugin system.

References