Composer and autoloading in plugins

Replace the hand-written autoloader with Composer's PSR-4 autoloader, add libraries safely and ship a build that cannot clash with other plugins.

  • Intermediate
  • 9 min read
  • Updated 17 Oct 2026

What you will learn

  • Autoload plugin classes
  • Avoid dependency conflicts
  • Build a release with production dependencies only

Before you start

The previous lesson gave the events plugin a hand-written autoloader for the MyPlugin\ namespace. Composer generates the same thing from a few lines of configuration, adds class maps for speed, and manages third-party libraries and development tools such as PHPUnit and PHPCS. This lesson moves the plugin to Composer and covers the part that is specific to WordPress: many plugins share one PHP process, so the libraries you ship can collide with someone else's.

composer.json for the plugin

Create composer.json in the plugin root:

JSON
{
    "name": "example/myplugin",
    "description": "Events, bookings and reminders for WordPress.",
    "type": "wordpress-plugin",
    "license": "GPL-2.0-or-later",
    "require": {
        "php": ">=8.1"
    },
    "autoload": {
        "psr-4": {
            "MyPlugin\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "MyPlugin\\Tests\\": "tests/"
        }
    },
    "config": {
        "optimize-autoloader": true,
        "sort-packages": true,
        "platform": {
            "php": "8.1"
        }
    }
}

The important parts:

  • autoload.psr-4 maps the MyPlugin\ prefix to src/, the same rule as the hand-written autoloader: MyPlugin\Bookings\Repository is src/Bookings/Repository.php. The backslash is doubled because it is escaped in JSON.
  • autoload-dev adds test classes, but only in development installs.
  • config.platform.php tells Composer to resolve dependencies as if the server ran PHP 8.1, your declared minimum. Without it, Composer picks package versions for the PHP on your laptop, which may be newer than your users'.
  • optimize-autoloader builds a class map so classes load from a lookup table rather than by checking the file system.

Generate the autoloader:

Shell
composer dump-autoload

This creates vendor/autoload.php and the vendor/composer/ folder.

Loading the autoloader

Replace the spl_autoload_register() block in the main file with a guarded require:

PHP
<?php
// myplugin/myplugin.php (after the constants)

if ( ! is_readable( MYPLUGIN_PATH . 'vendor/autoload.php' ) ) {
    add_action(
        'admin_notices',
        static function (): void {
            if ( current_user_can( 'activate_plugins' ) ) {
                echo '<div class="notice notice-error"><p>';
                esc_html_e( 'My Plugin is missing its vendor folder. Reinstall the plugin from a release package.', 'myplugin' );
                echo '</p></div>';
            }
        }
    );
    return;
}

require_once MYPLUGIN_PATH . 'vendor/autoload.php';

A return at the top level of an included file stops that file, so the rest of the bootstrap, including the activation hook and the plugins_loaded callback, never runs. The site stays up and the administrator learns what is wrong. This guard catches the most common mistake: installing the plugin straight from a Git repository where vendor/ is not committed.

Composer also generates vendor/composer/platform_check.php, which stops the request with an error when PHP is older than your dependencies need. Your plugin's Requires PHP header already prevents activation on old PHP, so many plugins switch the check off with "platform-check": false in config rather than risk a hard stop on a live site.

Helper functions

PSR-4 only covers classes. If you keep a few namespaced functions in a file, list it under files so Composer includes it on every request:

JSON
{
    "autoload": {
        "psr-4": {
            "MyPlugin\\": "src/"
        },
        "files": [
            "src/functions.php"
        ]
    }
}

Keep files entries small. They are loaded on every request whether used or not, which is the opposite of what autoloading is for.

Adding a library

Suppose the events plugin generates iCalendar files and you want a library for it. Install it as you would in any PHP project:

Shell
composer require example/icalendar

The library's classes are now available through the same autoloader. Before adding any dependency, check:

  • Does WordPress already do it? HTTP requests (wp_remote_get()), dates (wp_date()), email (wp_mail()), JSON responses, caching, cron and many utilities are built in. Reusing core avoids both weight and conflicts.
  • Is its licence compatible with the GPL? Your plugin is GPL-licensed, so everything you bundle must be GPL-compatible. MIT, BSD and LGPL libraries are; some others are not.
  • Is it maintained, and how many dependencies does it pull in? Every package you add is code you ship and must update.

Development tools go in require-dev, which is never shipped:

Shell
composer require --dev phpunit/phpunit yoast/phpunit-polyfills wp-coding-standards/wpcs

The dependency clash problem

In a normal PHP application, one composer.json resolves one version of each library. WordPress is different. Every plugin brings its own vendor/ folder, and all of them run in the same PHP process. PHP can only define a class once per request.

Imagine your plugin bundles version 3 of a logging library, and another plugin bundles version 1 of the same library. Both use the class name Example\Logger\Logger:

  1. WordPress loads plugins in order. The other plugin happens to load first and registers its autoloader.
  2. Some code asks for Example\Logger\Logger. Its autoloader answers first and loads version 1.
  3. Your code, written for version 3, calls a method that does not exist in version 1. The site crashes with a fatal error, or worse, behaves subtly wrong.

Which plugin wins depends on load order and on which code path touches the class first, so the bug appears on some sites and not others, and disappears when you deactivate an unrelated plugin. Composer's own autoloader is not the problem: each vendor/autoload.php gets a uniquely named loader class, so several can run side by side. The clash is between the libraries they load.

Prefixing vendor code

The reliable fix is to give your copies of the libraries their own namespace. A namespace-prefixing tool runs at build time and rewrites the bundled code, so Example\Logger\Logger becomes MyPlugin\Vendor\Example\Logger\Logger, in the library and in every reference to it. Your copy and the other plugin's copy now have different names and never collide.

A typical build pipeline:

  1. Install production dependencies.
  2. Run the prefixing tool over vendor/, writing the rewritten code to a folder such as vendor-prefixed/.
  3. Point your code at the prefixed names.
  4. Regenerate the autoloader so it covers the prefixed folder.

Prefixing tools need configuration for anything that is not a plain class reference: strings that contain class names, global functions and constants, and files the library loads dynamically. Run your tests against the prefixed build, not just your development tree, because that is what users run.

You do not prefix your own MyPlugin\ code, which is already unique to your plugin, and you should not prefix WordPress functions and classes, which must stay global.

If your plugin needs one small library, consider whether you need the library at all. A dependency you do not bundle is a conflict you never debug.

Shipping vendor

Users install a zip file. They do not run Composer, and many hosts do not have it. A distributed plugin therefore ships its production vendor/ folder, built from your lock file:

Shell
composer install --no-dev --optimize-autoloader --classmap-authoritative
  • --no-dev leaves out PHPUnit, PHPCS and everything else in require-dev.
  • --classmap-authoritative tells the autoloader to trust the class map completely and never fall back to the file system. It is the fastest mode, but any class missing from the map will not load, so only use it on a build where the class list is final.

Keep composer.lock in Git so every build uses the same versions. Keep vendor/ out of Git, and produce it in your release build instead. Lesson 24 turns this into a release process, with a .distignore file that keeps development files out of the zip:

Plain text
/.git
/.github
/node_modules
/tests
/assets/blocks
/assets/editor
.distignore
.gitignore
composer.json
composer.lock
package.json
package-lock.json
phpcs.xml.dist
phpunit.xml.dist

Whether composer.json ships is your choice: it does no harm, but it does nothing on a live site.

Composer for the whole site

Some teams manage entire WordPress sites with Composer, installing plugins as packages from a site-level composer.json. That is a deployment choice for one site. Your plugin should still work as a self-contained zip, because most of its users will install it from the Plugins screen.

Summary

  • Map MyPlugin\ to src/ with autoload.psr-4, set config.platform.php to your minimum PHP version, and require vendor/autoload.php behind an existence check.
  • Prefer WordPress APIs to libraries, check licences for GPL compatibility, and keep tools in require-dev.
  • Plugins share one PHP process, so two plugins bundling different versions of one library can break each other depending on load order.
  • Prefix bundled libraries into your own namespace at build time with a namespace-prefixing tool, and test the prefixed build.
  • Ship a production vendor/ built with --no-dev and an optimised class map; commit composer.lock, not vendor/.