Skip to main content
CakeDC Blog

Auditing Your Plugin Stack in CakePHP 5

What bin/cake plugin list Actually Tells You.

During a CakePHP 4 to 5 upgrade on a long-running application, I needed to review which of our thirty-something plugins were actually loaded, which were CLI-only, and which were meant to be absent from production. Application.php had a wall of addPlugin() calls accumulated over years, while composer.json had packages nobody was sure were still used or were part of obsolete features and the only way to reconcile the two was opening both files side by side and cross-referencing by hand.

CakePHP 5.1 shipped a command that ends that exercise, and using it on a real app running both plugin-loading mechanisms side by side also surfaced a bigger shift than the command itself: a new way to load and set up plugins in CakePHP 5.

The problem: plugins live in two places, and they drift

In CakePHP 4, "is this plugin loaded" meant reading Application::bootstrap() top to bottom, evaluating every if (PHP_SAPI === 'cli') and if (Configure::read('debug')) guard around it, and hoping nothing was wrapped in a condition three screens away from the addPlugin() call it protected.

"Is this plugin installed" was a separate question answered by composer.json. The two lists were never guaranteed to match, and a mismatch isn't automatically a problem, but on an app old enough to have had several maintainers, there was no reliable way to tell which mismatches mattered without comparing both files by hand.

bin/cake plugin list, added in CakePHP 5.1, answers both questions in one table.

What the command actually shows

Plugin Is Loaded Only Debug Only CLI Optional Version
CakeDC/Money 2.0.5
Authentication 3.3.7
Authorization 3.5.3
Bake 3.8.1
CakeDC/DbTest 3.0.2
DebugKit 5.2.4
Migrations 5.2.6
Reports 1.4.0

That's a trimmed version of the real output on the app I was working on. The first row is worth stopping on: CakeDC/Money is installed, has a version, and is not loaded anywhere. Grepping src/, templates/, plugins and config/ for the package's namespace turned up nothing, a dependency nobody remembered adding, still sitting in composer.json. That's exactly the kind of drift this command exists to catch.

Not every blank in that column means dead weight, though. Authentication and Authorization show up unloaded on the same app, and they are anything but unused: half a dozen classes extend Authentication\Authenticator\AbstractAuthenticator and Authentication\Identifier\AbstractIdentifier, and AuthenticationMiddleware sits in the middleware queue. Neither package was ever passed to addPlugin(), because neither needs to be. The addPlugin() method registers a plugin's routes, templates and bootstrap hooks, and packages like Authentication and Authorization ship none of those. You consume them as plain PHP classes through Composer's autoloader instead.

Is Loaded tells you whether a package was registered as a CakePHP plugin object, not whether its code runs anywhere. Before acting on anything the command flags, grep the package's namespace across src/, templates/, plugins and config/ first. CakeDC/Money earned the removal; Authentication did not.

The bigger change: plugin loading became declarative

The Only Debug, Only CLI and Optional columns are a direct readout of a new configuration format, and understanding where they come from is the actual payoff of running this command during an upgrade.

In CakePHP 4, gating a plugin was imperative code you wrote yourself:

// CakePHP 4 — src/Application.php
public function bootstrap(): void
{
    parent::bootstrap();

    if (PHP_SAPI === 'cli') {
        $this->addPlugin('Bake');
        $this->addPlugin('Migrations');
    }

    if (Configure::read('debug')) {
        $this->addOptionalPlugin('DebugKit');
    }

    $this->addPlugin('CakeDC/Users');
    $this->addPlugin('Reports');
}

Every gate was a hand-written if, and whether a missing plugin was fatal depended on whether you remembered to use addOptionalPlugin() instead of addPlugin(). Nothing enumerated these rules anywhere; you inferred them by reading code.

CakePHP 5 replaced this with config/plugins.php, a plain array that Cake\Http\BaseApplication::bootstrap() loads automatically, before your own Application::bootstrap() body runs:

// config/plugins.php
return [
    'Bake' => ['onlyCli' => true, 'optional' => true],
    'Migrations' => ['onlyCli' => true],
    'DebugKit' => ['onlyDebug' => true, 'optional' => true],
    'CakeDC/DbTest' => ['onlyDebug' => true, 'optional' => true],
];

onlyDebug, onlyCli and optional are declared, not scattered through conditionals, and they are precisely the three columns bin/cake plugin list prints.

Both mechanisms can coexist by design

Adopting the new declarative style is recommended, but nothing forces every addPlugin() call to move to config/plugins.php, both can coexist at the same time:

public function bootstrap(): void
{
    parent::bootstrap();

    $this->addPlugin('CakeDC/Users');
    $this->addPlugin('CakeDC/Admin');
    $this->addPlugin('Reports');
    $this->addPlugin('Notifications');
}
// config/plugins.php
return [
    'Bake' => ['onlyCli' => true, 'optional' => true],
    'Migrations' => ['onlyCli' => true],
    'DebugKit' => ['onlyDebug' => true, 'optional' => true],
    'CakeDC/DbTest' => ['onlyDebug' => true, 'optional' => true],
    'IdeHelper' => ['onlyDebug' => true, 'optional' => true],
];

The tooling plugins are exactly the ones where a wrong load in production matters (nobody wants DebugKit's toolbar on a live site, or Bake console commands bundled into a deploy that skips --no-dev), so those are the ones that most benefit from the safety of declarative, testable configuration.

CakePHP 5 supports this split on purpose: PluginConfig::getInstalledPlugins() merges both sources before the command ever renders a row, so bin/cake plugin list audits the effective state regardless of which mechanism loaded each plugin.

One detail worth knowing before you lean on optional the way the CakePHP core team does for Bake and DebugKit: check whether the underlying Composer package lives in require or require-dev. cakephp/migrations was in require on our app deliberately, since migrations need to run from a production deploy step, so it got onlyCli without optional. cakephp/debug_kit and dereuromark/cakephp-ide-helper were in require-dev, so they got both onlyDebug and optional: without optional, a --no-dev production install would throw MissingPluginException the moment the app tried to boot, because the package simply isn't there. onlyDebug alone doesn't save you from that, it only stops the plugin from loading, it doesn't stop the app from throwing if the config still lists it and the package is missing.

What can you do in your application?

  1. Run bin/cake plugin list on your CakePHP 5 app.
  2. Anything with a version and no X in "Is Loaded" is worth a second look, grep its namespace across src/, templates/, plugins and config/ first, before running composer remove. Some packages (Authentication, Authorization) are consumed as plain classes and never get loaded as a plugin, so a blank there isn't proof of dead weight on its own.
  3. For anything still gated by if (PHP_SAPI === 'cli') or if (Configure::read('debug')) in Application.php, check whether its package is require or require-dev, then move it to config/plugins.php with the matching onlyCli/onlyDebug/optional combination.
  4. You can leave the rest. You don't need to migrate every plugin in one sitting to get value from the command.

If your CakePHP application has accumulated this kind of drift over several years and several maintainers, plugins nobody's sure are still needed, loading logic nobody wants to touch, an upgrade to CakePHP 5 that keeps getting pushed back, this is exactly the kind of work CakeDC does. Get in touch and let's talk about where your project stands and how to move it forward.

Further reading

Back to all articles
We Bake with CakePHP