Before and after: The cross-plugin collision, and the fix. One global PHP namespace: Plugin A requires Guzzle v7, Plugin B requires Guzzle v6, Both declare GuzzleHttp\Client, Fatal: cannot redeclare class. Vendor-scoped in the build: Acme\Billing\Vendor\GuzzleHttp, Acme\Crm\Vendor\GuzzleHttp, Both majors load in isolation, Strauss or php-scoper, in CI.

Structuring WordPress at scale: the WordPress Composer PSR-4 and npm model

At scale the problem is not one plugin. It is governing many plugins and themes as one dependency system across two graphs: Composer and PSR-4 for PHP, npm for JS. This guide shows the two-graph model, the cross-plugin namespace collision that breaks it, the vendor scoping that fixes it live, measured autoloader numbers, and a five-step migration you can run without breaking a production site.

The real problem is not one plugin. It is many.#

Search for how to add Composer to a WordPress plugin and every result answers the same small question. Each one wires up a single plugin, in isolation, on a clean install. That is a fine first step, yet it is not the problem you actually have once a site grows past a few moving parts.

This is the ground we work on when we build and maintain WordPress at scale at Atyantik, where a codebase has to stay clean across many plugins and several years. At scale you run many plugins and themes together. They share one PHP process, one global namespace, and one front-end build surface. So the failures are not "how do I autoload a class". They are "why did two plugins just fight over the same library", and "why is the admin screen loading twelve separate scripts". Those are system problems, and a single-plugin tutorial never shows them.

What actually breaks in an untooled codebase#

Before the fix, feel the pain. This is a hand-wired codebase after two years, set next to the same code under Composer, PSR-4, and npm. Each row is one concern, so you can see the untooled failure and the tooled answer side by side.

Untooled versus tooled, one WordPress concern per row
ConcernUntooledTooled
Dependency loadingLong require_once chains that must load in the right order.Composer resolves every package and its versions for you.
Class autoloadingA class rename means grepping every include by hand.PSR-4 maps a namespace to a folder, so classes load on reference.
Shared librariesTwo plugins bundle different copies of the same library and clash.Vendor scoping isolates each library, so plugins never clash.
The loader fileMerge conflicts land in a giant hand-maintained loader file.The loader is generated, so there is no file to conflict on.
Front-end assetsThe admin screen ships a dozen separate, unminified scripts.npm builds one hashed, minified bundle per surface.

Why the single-plugin tutorials stop short#

The incumbent guides are not wrong. They are simply scoped to one plugin, because that is the easy demo. The hard part only appears when a second plugin arrives. Two plugins in the same PHP process cannot both declare GuzzleHttp\Client, so "at scale" really means many plugins sharing one global namespace and one build. So the useful model is not a plugin. It is two dependency graphs, reconciled across every plugin and theme you ship.

The two-graph model: Composer for PHP, npm for JS#

Hold one picture for the whole site. Every plugin and theme pulls two kinds of dependency. First, PHP packages resolved by Composer and loaded through PSR-4. Second, JS packages resolved by npm and compiled by a bundler. These are two separate graphs with two separate lock files, and they meet only at the organization root.

Two dependency graphs, reconciled at the organization rootEach plugin and theme has a Composer graph (PHP, loaded by PSR-4) and an npm graph (JS, compiled by a bundler). The root composer.json and package.json govern both across every plugin, which is what 'at scale' actually means.

Once you see the two graphs, the rest of this guide is a zoom into each one. Graph one is Composer and PSR-4, where the cross-plugin collision lives. Graph two is npm, where the deploy contract lives. Keep the picture in mind, because both graphs need governing together.

Graph one: WordPress Composer PSR-4 as the PHP dependency graph#

The PHP half is where a WordPress Composer PSR-4 setup earns its place, and where the hardest failure hides. This section builds it in three moves. First, how PSR-4 maps a namespace to a directory. Second, how the autoloader resolves a class and how to tune it. Third, the cross-plugin collision and the vendor scoping that resolves it.

How PSR-4 maps a namespace to a directory#

PSR-4 (the PHP-FIG spec) is a plain rule. A namespace prefix maps to a base directory, and the rest of the class name maps to the path below it. You declare that mapping once, in composer.json, and Composer generates the loader.

composer.json · json
{
  "name": "acme/billing-plugin",
  "require": {
    "php": ">=8.1",
    "guzzlehttp/guzzle": "^7.0"
  },
  "autoload": {
    "psr-4": {
      "Acme\\Billing\\": "src/"
    }
  },
  "config": {
    "optimize-autoloader": true,
    "sort-packages": true
  }
}

With that block in place, the resolution is mechanical. The prefix Acme\Billing\ points at src/, and every segment after it becomes a folder. So a reader can trace any class straight to its file, and Composer does the same at runtime.

How PSR-4 resolves a file path to a fully-qualified class name
File pathPSR-4 prefixResolves to class
src/Admin/SettingsPage.phpAcme\Billing\ maps to src/Acme\Billing\Admin\SettingsPage
src/Api/InvoiceController.phpsame prefixAcme\Billing\Api\InvoiceController
src/Model/Invoice.phpsame prefixAcme\Billing\Model\Invoice
src/Support/Money.phpsame prefixAcme\Billing\Support\Money

Autoloading on first reference, and dump-autoload -o#

By default Composer resolves a PSR-4 class by walking the filesystem the first time you reference it. That is convenient locally, because a new file is picked up with no rebuild. Yet it costs a filesystem probe per unmapped class, which adds up under load. For production you build a static class map instead.

build.sh · bash
# Development: quick rebuilds. Classes are found by walking the filesystem.
composer dump-autoload

# Production: build one static class map, so there is no folder probing at runtime.
composer dump-autoload --optimize --classmap-authoritative

The difference is measurable, not cosmetic. The chart below autoloads the same 800 classes three ways on a cold request. Numbers are illustrative and will vary by host, yet the shape holds everywhere. An authoritative class map with an OPcache preload is the fastest by a wide margin.

Show data table
Time to autoload 800 classes on a cold request (microseconds, illustrative)
Item Time to autoload
PSR-4, unoptimized 3,800 us
Classmap, dump -o 1,100 us
Authoritative + preload 480 us

The authoritative class map with an OPcache preload autoloads the same 800 classes about eight times faster than unoptimized PSR-4. The gain is pure deploy configuration, with no code change.

Figure Time to autoload 800 classes on a cold request (microseconds, illustrative) Autoload time for 800 classes on a cold request under each Composer strategy. Modelled, not measured.

Pick the mode per environment, not once for all. Development wants the walk-the-filesystem default, because it needs no rebuild. Production wants the authoritative map, because it never probes disk. The table names the trade-off for each mode.

Autoloader modes and where each one belongs
Autoloader modeWhen it resolvesFilesystem probingBest for
PSR-4 defaultOn first class referenceYes, walks foldersLocal development
Classmap, dump -oPrebuilt map at deployOnly for unmapped classesStaging and CI
Authoritative classmapPrebuilt map, no fallbackNeverProduction
Classmap plus OPcache preloadLoaded into shared memory at startNeverHigh-traffic production

The cross-plugin collision problem, and vendor scoping#

Here is the failure no single-plugin tutorial shows. Two plugins each require Guzzle, but different major versions. Both versions declare the class GuzzleHttp\Client in the one global PHP namespace. As a result the second plugin to load hits a fatal "cannot redeclare class", and the site goes down. Toggle vendor scoping in the island below and watch the collision resolve.

Two plugins, one global PHP namespace
Vendor scoping

Fatal error: cannot redeclare class GuzzleHttp\Client. Two plugins load it into the one global PHP namespace.

  1. Plugin A Billing

    Composer graph (PHP)

    • guzzlehttp/guzzle v6 GuzzleHttp\Client
    • monolog/monolog Monolog\Logger

    npm graph (JS)

    • react
    • react-dom
    • vite
  2. Plugin B CRM

    Composer graph (PHP)

    • guzzlehttp/guzzle v7 GuzzleHttp\Client
    • league/csv League\Csv\Reader

    npm graph (JS)

    • react
    • react-dom
    • vite
  3. Plugin C Analytics

    Composer graph (PHP)

    • monolog/monolog Monolog\Logger

    npm graph (JS)

    • chart.js
    • vite

With vendor scoping on, the build rewrites each copy to Acme\Billing\Vendor\GuzzleHttp\Client and Acme\Crm\Vendor\GuzzleHttp\Client. The two classes no longer share a name, so both plugins load and the fatal error is gone.

Plugin A loads Guzzle v6 and Plugin B loads Guzzle v7. Both declare GuzzleHttpClient in the one shared PHP namespace, so the second load is a fatal error. Turn on vendor scoping to rewrite the prefixes and isolate each copy.

Vendor scoping is the fix, and it runs in your build, never at runtime. A scoper rewrites the namespace of each bundled library to a plugin-specific prefix. So Plugin A ships Acme\Billing\Vendor\GuzzleHttp\Client and Plugin B ships Acme\Crm\Vendor\GuzzleHttp\Client. The two classes no longer share a name, so both load in isolation. Two tools do this well, and the config is small in both.

composer.json (extra) · json
{
  "extra": {
    "strauss": {
      "target_directory": "vendor-prefixed",
      "namespace_prefix": "Acme\\Billing\\Vendor\\",
      "classmap_prefix": "Acme_Billing_",
      "packages": ["guzzlehttp/guzzle"]
    }
  }
}

Both tools reach the same result, so pick on ergonomics. Strauss is configured inside composer.json and is built for WordPress plugin distribution. php-scoper uses a PHP config file and is more general. Either way, scoping is what makes a WordPress Composer PSR-4 setup safe to run beside other plugins you do not control.

Graph two: npm for front-end discipline#

The second graph governs JavaScript, and it follows the same shape. npm resolves the JS packages, a bundler compiles them into a small set of hashed files, and WordPress enqueues the built output. The mistake to avoid is treating JS like PHP and shipping raw source. Instead you build once and enqueue the result.

package.json and a real build step#

Each plugin that owns a front end gets its own package.json and its own build. The manifest below declares the runtime packages, the dev tooling, and two scripts: a watch build for development and a one-shot build for release.

package.json · json
{
  "name": "@acme/billing-admin",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite build --watch",
    "build": "vite build"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "vite": "^5.0.0"
  }
}

WordPress then loads the compiled file, not the source. Use filemtime as the version so the browser cache busts whenever the build changes. This keeps the front end fast and the cache honest.

plugin.php · php
// Enqueue the BUILT, content-hashed asset, never raw source. filemtime busts
// the cache when the build changes, so browsers never serve a stale bundle.
wp_enqueue_script(
    'acme-billing-admin',
    plugins_url('build/admin.js', __FILE__),
    array(),
    filemtime(plugin_dir_path(__FILE__) . 'build/admin.js'),
    true
);

The build-vs-commit deploy contract#

Both graphs meet at one rule: build artifacts are built, not committed. You commit source and lock files. Your CI builds the vendor directory and the compiled assets. The deploy artifact carries the result, and the WordPress host only ever sees the built output.

The build-versus-commit deploy contractCommit source and lock files. CI runs composer install --no-dev -o, scopes the vendor namespaces, and builds the JS. The deploy artifact carries the scoped vendor/ and the compiled assets. Neither vendor/ nor node_modules is ever committed.

This contract keeps the repository small and the deploy reproducible. Because vendor/ and node_modules never enter git, a checkout stays fast and merge conflicts stay rare. Because the artifact is built in CI from lock files, two deploys of the same commit are byte-identical. That is the payoff of governing both graphs together.

A non-breaking migration in five steps#

You do not adopt this on a live site in one commit. You adopt it in small, reversible steps, each of which ships on its own. The sequence below moves a hand-wired plugin onto a WordPress Composer PSR-4 setup without a big-bang rewrite. Each step is safe to stop at.

  1. Add composer.json without touching runtime

    Commit a composer.json that declares your PSR-4 prefix and dev tooling. Do not require it from WordPress yet. Nothing loads differently, so nothing can break.

  2. Move one leaf class behind the autoloader

    Pick one class with no include chain. Namespace it, drop its require_once, and let Composer autoload it. Ship, then watch that one path in production.

  3. Convert the rest folder by folder

    Repeat per folder, newest code first. Keep the old require_once lines until each class is proven under the autoloader, then delete them.

  4. Scope the vendor libraries

    Once third-party packages live in vendor/, run Strauss or php-scoper in your build. Now no shared library can collide with another plugin.

  5. Switch production to an authoritative classmap

    Add composer install --no-dev -o to your deploy. Build the artifact in CI, never commit vendor/, and ship the class map.

Notice that a rollback at any step is a single revert, because each step ships alone. Then the risk stays low even on a busy site. That is the whole reason to migrate folder by folder rather than all at once.

When not to structure WordPress at scale this way#

This model earns its keep at scale. It is overhead on a small site. So be honest about where you are before you adopt all of it.

Where to go next#

The npm graph here sits next to the wider JavaScript platform, so if you are choosing what to adopt on the front end, our guide to modern ECMAScript features that shipped for 2026 covers the language side of that second graph. The build-versus-commit contract also depends on a disciplined branching model, and a GitFlow branching workflow for teams is how we keep source and artifacts separated across releases. Together those two reads round out the graph-governance picture this post opened with.

If you want a second set of hands running WordPress at scale, you can hire WordPress developers or hire PHP developers from our team, and we cover the same ground in our CMS development and performance work. No pressure and no lock-in: everything above is standard, documented tooling you own outright. If it helps to talk it through, reach out and we will point you at the right first step.

Running WordPress at scale and want a second opinion on your Composer, autoloading, and build setup? No pressure and no lock-in.

Talk through your WordPress setup

Tushar Sharma

Software Engineer, Atyantik Technologies

Tushar is a Software Engineer at Atyantik Technologies, a software product studio that has delivered 50-plus enterprise engagements across 7 countries since 2015. He works on the parts of a WordPress codebase that decide whether it stays fast and maintainable as it grows: dependency and build tooling, autoloading, plugin and theme structure, and the schema underneath a busy site.

More from Tushar SharmaHire WordPress developersCMS development

Keep reading