Browse Source

Merge pull request #11564 from cakephp/3.next-plugins

Implement Plugin Classes
Mark Story 8 years ago
parent
commit
ec32168d69

+ 72 - 23
src/Console/CommandRunner.php

@@ -22,10 +22,12 @@ use Cake\Console\ConsoleIo;
 use Cake\Console\Exception\StopException;
 use Cake\Console\Shell;
 use Cake\Core\ConsoleApplicationInterface;
-use Cake\Event\EventApplicationInterface;
+use Cake\Core\PluginApplicationInterface;
 use Cake\Event\EventDispatcherInterface;
 use Cake\Event\EventDispatcherTrait;
+use Cake\Event\EventManager;
 use Cake\Utility\Inflector;
+use InvalidArgumentException;
 use RuntimeException;
 
 /**
@@ -33,7 +35,14 @@ use RuntimeException;
  */
 class CommandRunner implements EventDispatcherInterface
 {
-    use EventDispatcherTrait;
+    /**
+     * Alias methods away so we can implement proxying methods.
+     */
+    use EventDispatcherTrait {
+        eventManager as private _eventManager;
+        getEventManager as private _getEventManager;
+        setEventManager as private _setEventManager;
+    }
 
     /**
      * The application console commands are being run for.
@@ -64,7 +73,7 @@ class CommandRunner implements EventDispatcherInterface
      */
     public function __construct(ConsoleApplicationInterface $app, $root = 'cake')
     {
-        $this->setApp($app);
+        $this->app = $app;
         $this->root = $root;
         $this->aliases = [
             '--version' => 'version',
@@ -97,23 +106,6 @@ class CommandRunner implements EventDispatcherInterface
     }
 
     /**
-     * Set the application.
-     *
-     * @param \Cake\Core\ConsoleApplicationInterface $app The application to run CLI commands for.
-     * @return $this
-     */
-    public function setApp(ConsoleApplicationInterface $app)
-    {
-        $this->app = $app;
-
-        if ($app instanceof EventDispatcherInterface) {
-            $this->setEventManager($app->getEventManager());
-        }
-
-        return $this;
-    }
-
-    /**
      * Run the command contained in $argv.
      *
      * Use the application to do the following:
@@ -137,6 +129,10 @@ class CommandRunner implements EventDispatcherInterface
             'help' => HelpCommand::class,
         ]);
         $commands = $this->app->console($commands);
+        if ($this->app instanceof PluginApplicationInterface) {
+            $commands = $this->app->pluginConsole($commands);
+        }
+
         if (!($commands instanceof CommandCollection)) {
             $type = getTypeName($commands);
             throw new RuntimeException(
@@ -178,16 +174,69 @@ class CommandRunner implements EventDispatcherInterface
      * Application bootstrap wrapper.
      *
      * Calls `bootstrap()` and `events()` if application implements `EventApplicationInterface`.
+     * After the application is bootstrapped and events are attached, plugins are bootstrapped
+     * and have their events attached.
      *
      * @return void
      */
     protected function bootstrap()
     {
         $this->app->bootstrap();
-        if ($this->app instanceof EventApplicationInterface) {
-            $eventManager = $this->app->events($this->getEventManager());
-            $this->setEventManager($eventManager);
+        if ($this->app instanceof PluginApplicationInterface) {
+            $this->app->pluginBootstrap();
+        }
+    }
+
+    /**
+     * Get the application's event manager or the global one.
+     *
+     * @return \Cake\Event\EventManagerInterface
+     */
+    public function getEventManager()
+    {
+        if ($this->app instanceof PluginApplicationInterface) {
+            return $this->app->getEventManager();
+        }
+
+        return EventManager::instance();
+    }
+
+    /**
+     * Get/set the application's event manager.
+     *
+     * If the application does not support events and this method is used as
+     * a setter, an exception will be raised.
+     *
+     * @param \Cake\Event\EventManager|null $events The event manager to set.
+     * @return \Cake\Event\EventManager|$this
+     * @deprecated 3.6.0 Will be removed in 4.0
+     */
+    public function eventManager(EventManager $events = null)
+    {
+        deprecationWarning('eventManager() is deprecated. Use getEventManager()/setEventManager() instead.');
+        if ($events === null) {
+            return $this->getEventManager();
         }
+
+        return $this->setEventManager($events);
+    }
+
+    /**
+     * Get/set the application's event manager.
+     *
+     * If the application does not support events and this method is used as
+     * a setter, an exception will be raised.
+     *
+     * @param \Cake\Event\EventManager $events The event manager to set.
+     * @return $this
+     */
+    public function setEventManager(EventManager $events)
+    {
+        if ($this->app instanceof PluginApplicationInterface) {
+            return $this->app->setEventManager($events);
+        }
+
+        throw new InvalidArgumentException('Cannot set the event manager, the application does not support events.');
     }
 
     /**

+ 266 - 0
src/Core/BasePlugin.php

@@ -0,0 +1,266 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright 2005-2011, Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.6.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Core;
+
+use Cake\Event\EventManagerInterface;
+use InvalidArgumentException;
+use ReflectionClass;
+
+/**
+ * Base Plugin Class
+ *
+ * Every plugin should extends from this class or implement the interfaces and
+ * include a plugin class in it's src root folder.
+ */
+class BasePlugin implements PluginInterface
+{
+
+    /**
+     * Do bootstrapping or not
+     *
+     * @var bool
+     */
+    protected $bootstrapEnabled = true;
+
+    /**
+     * Are events enabled.
+     *
+     * @var bool
+     */
+    protected $eventsEnabled = true;
+
+    /**
+     * Load routes or not
+     *
+     * @var bool
+     */
+    protected $routesEnabled = true;
+
+    /**
+     * Enable middleware
+     *
+     * @var bool
+     */
+    protected $middlewareEnabled = true;
+
+    /**
+     * Console middleware
+     *
+     * @var bool
+     */
+    protected $consoleEnabled = true;
+
+    /**
+     * The path to this plugin.
+     *
+     * @var string
+     */
+    protected $path;
+
+    /**
+     * The class path for this plugin.
+     *
+     * @var string
+     */
+    protected $classPath;
+
+    /**
+     * The config path for this plugin.
+     *
+     * @var string
+     */
+    protected $configPath;
+
+    /**
+     * The name of this plugin
+     *
+     * @var string
+     */
+    protected $name;
+
+    /**
+     * Constructor
+     *
+     * @param array $options Options
+     */
+    public function __construct(array $options = [])
+    {
+        foreach (static::VALID_HOOKS as $key) {
+            if (isset($options[$key])) {
+                $this->{"{$key}Enabled"} = (bool)$options[$key];
+            }
+        }
+        foreach (['name', 'path', 'classPath', 'configPath'] as $path) {
+            if (isset($options[$path])) {
+                $this->{$path} = $options[$path];
+            }
+        }
+
+        $this->initialize();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function initialize()
+    {
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getName()
+    {
+        if ($this->name) {
+            return $this->name;
+        }
+        $parts = explode('\\', get_class($this));
+        array_pop($parts);
+        $this->name = implode('/', $parts);
+
+        return $this->name;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getPath()
+    {
+        if ($this->path) {
+            return $this->path;
+        }
+        $reflection = new ReflectionClass($this);
+        $path = dirname($reflection->getFileName());
+
+        // Trim off src
+        if (substr($path, -3) === 'src') {
+            $path = substr($path, 0, -3);
+        }
+        $this->path = rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
+
+        return $this->path;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getConfigPath()
+    {
+        if ($this->configPath) {
+            return $this->configPath;
+        }
+        $path = $this->getPath();
+
+        return $path . 'config' . DIRECTORY_SEPARATOR;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getClassPath()
+    {
+        if ($this->classPath) {
+            return $this->classPath;
+        }
+        $path = $this->getPath();
+
+        return $path . 'src' . DIRECTORY_SEPARATOR;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function enable($hook)
+    {
+        $this->checkHook($hook);
+        $this->{"{$hook}Enabled}"} = true;
+
+        return $this;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function disable($hook)
+    {
+        $this->checkHook($hook);
+        $this->{"{$hook}Enabled"} = false;
+
+        return $this;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function isEnabled($hook)
+    {
+        $this->checkHook($hook);
+
+        return $this->{"{$hook}Enabled"} === true;
+    }
+
+    /**
+     * Check if a hook name is valid
+     *
+     * @param string $hook The hook name to check
+     * @throws \InvalidArgumentException on invalid hooks
+     * @return void
+     */
+    protected function checkHook($hook)
+    {
+        if (!in_array($hook, static::VALID_HOOKS)) {
+            throw new InvalidArgumentException(
+                "`$hook` is not a valid hook name. Must be one of " . implode(', ', static::VALID_HOOKS)
+            );
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function routes($routes)
+    {
+        $path = $this->getConfigPath() . DS . 'routes.php';
+        if (file_exists($path)) {
+            require $path;
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function bootstrap(PluginApplicationInterface $app)
+    {
+        $bootstrap = $this->getConfigPath() . DS . 'bootstrap.php';
+        if (file_exists($bootstrap)) {
+            require $bootstrap;
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function console($commands)
+    {
+        return $commands;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function middleware($middleware)
+    {
+        return $middleware;
+    }
+}

+ 59 - 46
src/Core/Plugin.php

@@ -30,9 +30,9 @@ class Plugin
     /**
      * Holds a list of all loaded plugins and their configuration
      *
-     * @var array
+     * @var \Cake\Core\PluginCollection
      */
-    protected static $_plugins = [];
+    protected static $plugins;
 
     /**
      * Class loader instance
@@ -127,7 +127,8 @@ class Plugin
             'bootstrap' => false,
             'routes' => false,
             'classBase' => 'src',
-            'ignoreMissing' => false
+            'ignoreMissing' => false,
+            'name' => $plugin
         ];
 
         if (!isset($config['path'])) {
@@ -154,7 +155,8 @@ class Plugin
             $config['configPath'] = $config['path'] . 'config' . DIRECTORY_SEPARATOR;
         }
 
-        static::$_plugins[$plugin] = $config;
+        // Use stub plugins as this method will be removed long term.
+        static::getCollection()->add(new BasePlugin($config));
 
         if ($config['autoload'] === true) {
             if (empty(static::$_loader)) {
@@ -242,12 +244,13 @@ class Plugin
             $plugins = array_unique($plugins);
         }
 
+        $collection = static::getCollection();
         foreach ($plugins as $p) {
             $opts = isset($options[$p]) ? $options[$p] : null;
             if ($opts === null && isset($options[0])) {
                 $opts = $options[0];
             }
-            if (isset(static::$_plugins[$p])) {
+            if ($collection->has($p)) {
                 continue;
             }
             static::load($p, (array)$opts);
@@ -257,70 +260,63 @@ class Plugin
     /**
      * Returns the filesystem path for a plugin
      *
-     * @param string $plugin name of the plugin in CamelCase format
+     * @param string $name name of the plugin in CamelCase format
      * @return string path to the plugin folder
      * @throws \Cake\Core\Exception\MissingPluginException if the folder for plugin was not found or plugin has not been loaded
      */
-    public static function path($plugin)
+    public static function path($name)
     {
-        if (empty(static::$_plugins[$plugin])) {
-            throw new MissingPluginException(['plugin' => $plugin]);
-        }
+        $plugin = static::getCollection()->get($name);
 
-        return static::$_plugins[$plugin]['path'];
+        return $plugin->getPath();
     }
 
     /**
      * Returns the filesystem path for plugin's folder containing class folders.
      *
-     * @param string $plugin name of the plugin in CamelCase format.
+     * @param string $name name of the plugin in CamelCase format.
      * @return string Path to the plugin folder container class folders.
      * @throws \Cake\Core\Exception\MissingPluginException If plugin has not been loaded.
      */
-    public static function classPath($plugin)
+    public static function classPath($name)
     {
-        if (empty(static::$_plugins[$plugin])) {
-            throw new MissingPluginException(['plugin' => $plugin]);
-        }
+        $plugin = static::getCollection()->get($name);
 
-        return static::$_plugins[$plugin]['classPath'];
+        return $plugin->getClassPath();
     }
 
     /**
      * Returns the filesystem path for plugin's folder containing config files.
      *
-     * @param string $plugin name of the plugin in CamelCase format.
+     * @param string $name name of the plugin in CamelCase format.
      * @return string Path to the plugin folder container config files.
      * @throws \Cake\Core\Exception\MissingPluginException If plugin has not been loaded.
      */
-    public static function configPath($plugin)
+    public static function configPath($name)
     {
-        if (empty(static::$_plugins[$plugin])) {
-            throw new MissingPluginException(['plugin' => $plugin]);
-        }
+        $plugin = static::getCollection()->get($name);
 
-        return static::$_plugins[$plugin]['configPath'];
+        return $plugin->getConfigPath();
     }
 
     /**
      * Loads the bootstrapping files for a plugin, or calls the initialization setup in the configuration
      *
-     * @param string $plugin name of the plugin
+     * @param string $name name of the plugin
      * @return mixed
      * @see \Cake\Core\Plugin::load() for examples of bootstrap configuration
      */
-    public static function bootstrap($plugin)
+    public static function bootstrap($name)
     {
-        $config = static::$_plugins[$plugin];
-        if ($config['bootstrap'] === false) {
+        $plugin = static::getCollection()->get($name);
+        if (!$plugin->isEnabled('bootstrap')) {
             return false;
         }
-        if ($config['bootstrap'] === true) {
-            return static::_includeFile(
-                $config['configPath'] . 'bootstrap.php',
-                $config['ignoreMissing']
-            );
-        }
+
+        return static::_includeFile(
+            $plugin->getConfigPath() . 'bootstrap.php',
+            true
+        );
     }
 
     /**
@@ -329,27 +325,27 @@ class Plugin
      * If you need fine grained control over how routes are loaded for plugins, you
      * can use {@see Cake\Routing\RouteBuilder::loadPlugin()}
      *
-     * @param string|null $plugin name of the plugin, if null will operate on all
+     * @param string|null $name name of the plugin, if null will operate on all
      *   plugins having enabled the loading of routes files.
      * @return bool
      */
-    public static function routes($plugin = null)
+    public static function routes($name = null)
     {
-        if ($plugin === null) {
+        if ($name === null) {
             foreach (static::loaded() as $p) {
                 static::routes($p);
             }
 
             return true;
         }
-        $config = static::$_plugins[$plugin];
-        if ($config['routes'] === false) {
+        $plugin = static::getCollection()->get($name);
+        if (!$plugin->isEnabled('routes')) {
             return false;
         }
 
         return (bool)static::_includeFile(
-            $config['configPath'] . 'routes.php',
-            $config['ignoreMissing']
+            $plugin->getConfigPath() . 'routes.php',
+            true
         );
     }
 
@@ -364,12 +360,15 @@ class Plugin
     public static function loaded($plugin = null)
     {
         if ($plugin !== null) {
-            return isset(static::$_plugins[$plugin]);
+            return static::getCollection()->has($plugin);
+        }
+        $names = [];
+        foreach (static::getCollection() as $plugin) {
+            $names[] = $plugin->getName();
         }
-        $return = array_keys(static::$_plugins);
-        sort($return);
+        sort($names);
 
-        return $return;
+        return $names;
     }
 
     /**
@@ -381,9 +380,9 @@ class Plugin
     public static function unload($plugin = null)
     {
         if ($plugin === null) {
-            static::$_plugins = [];
+            static::$plugins = null;
         } else {
-            unset(static::$_plugins[$plugin]);
+            static::getCollection()->remove($plugin);
         }
     }
 
@@ -402,4 +401,18 @@ class Plugin
 
         return include $file;
     }
+
+    /**
+     * Get the shared plugin collection.
+     *
+     * @return \Cake\Core\PluginCollection
+     */
+    public static function getCollection()
+    {
+        if (!isset(static::$plugins)) {
+            static::$plugins = new PluginCollection();
+        }
+
+        return static::$plugins;
+    }
 }

+ 67 - 0
src/Core/PluginApplicationInterface.php

@@ -0,0 +1,67 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.6.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Core;
+
+use Cake\Event\EventDispatcherInterface;
+use Cake\Event\EventManagerInterface;
+
+/**
+ * Interface for Applications that leverage plugins & events.
+ *
+ * Events can be bound to the application event manager during
+ * the application's bootstrap and plugin bootstrap.
+ */
+interface PluginApplicationInterface extends EventDispatcherInterface
+{
+    /**
+     * Add a plugin to the loaded plugin set.
+     *
+     * @param string|\Cake\Core\PluginInterface $name The plugin name or plugin object.
+     * @param array $config The configuration data for the plugin if using a string for $name
+     * @return $this
+     */
+    public function addPlugin($name, array $config = []);
+
+    /**
+     * Run bootstrap logic for loaded plugins.
+     *
+     * @return void
+     */
+    public function pluginBootstrap();
+
+    /**
+     * Run routes hooks for loaded plugins
+     *
+     * @param \Cake\Routing\RouteBuilder $routes The route builder to use.
+     * @return \Cake\Routing\RouteBuilder
+     */
+    public function pluginRoutes($routes);
+
+    /**
+     * Run middleware hooks for plugins
+     *
+     * @param \Cake\Http\MiddlewareQueue $middleware The MiddlewareQueue to use.
+     * @return \Cake\Http\MiddlewareQueue
+     */
+    public function pluginMiddleware($middleware);
+
+    /**
+     * Run console hooks for plugins
+     *
+     * @param \Cake\Console\CommandCollection $commands The CommandCollection to use.
+     * @return \Cake\Console\CommandCollection
+     */
+    public function pluginConsole($commands);
+}

+ 147 - 0
src/Core/PluginCollection.php

@@ -0,0 +1,147 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright 2005-2011, Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.6.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Core;
+
+use ArrayIterator;
+use Cake\Core\Exception\MissingPluginException;
+use Countable;
+use InvalidArgumentException;
+use IteratorAggregate;
+use RuntimeException;
+
+/**
+ * Plugin Collection
+ *
+ * Holds onto plugin objects loaded into an application, and
+ * provides methods for iterating, and finding plugins based
+ * on criteria.
+ */
+class PluginCollection implements IteratorAggregate, Countable
+{
+    /**
+     * Plugin list
+     *
+     * @var array
+     */
+    protected $plugins = [];
+
+    /**
+     * Constructor
+     *
+     * @param array $plugins The map of plugins to add to the collection.
+     */
+    public function __construct(array $plugins = [])
+    {
+        foreach ($plugins as $plugin) {
+            $this->add($plugin);
+        }
+    }
+
+    /**
+     * Add a plugin to the collection
+     *
+     * Plugins will be keyed by their names.
+     *
+     * @param \Cake\Core\PluginInterface $plugin The plugin to load.
+     * @return $this
+     */
+    public function add(PluginInterface $plugin)
+    {
+        $name = $plugin->getName();
+        $this->plugins[$name] = $plugin;
+
+        return $this;
+    }
+
+    /**
+     * Remove a plugin from the collection if it exists.
+     *
+     * @param string $name The named plugin.
+     * @return $this
+     */
+    public function remove($name)
+    {
+        unset($this->plugins[$name]);
+
+        return $this;
+    }
+
+    /**
+     * Check whether the named plugin exists in the collection.
+     *
+     * @param string $name The named plugin.
+     * @return bool
+     */
+    public function has($name)
+    {
+        return isset($this->plugins[$name]);
+    }
+
+    /**
+     * Get the a plugin by name
+     *
+     * @param string $name The plugin to get.
+     * @return \Cake\Core\PluginInterface The plugin.
+     * @throws \Cake\Core\Exception\MissingPluginException when unknown plugins are fetched.
+     */
+    public function get($name)
+    {
+        if (!$this->has($name)) {
+            throw new MissingPluginException(['plugin' => $name]);
+        }
+
+        return $this->plugins[$name];
+    }
+
+    /**
+     * Implementation of IteratorAggregate.
+     *
+     * @return \ArrayIterator
+     */
+    public function getIterator()
+    {
+        return new ArrayIterator($this->plugins);
+    }
+
+    /**
+     * Implementation of Countable.
+     *
+     * Get the number of plugins in the collection.
+     *
+     * @return int
+     */
+    public function count()
+    {
+        return count($this->plugins);
+    }
+
+    /**
+     * Filter the plugins to those with the named hook enabled.
+     *
+     * @param string $hook The hook to filter plugins by
+     * @return \Generator A generator containing matching plugins.
+     * @throws \InvalidArgumentException on invalid hooks
+     */
+    public function with($hook)
+    {
+        if (!in_array($hook, PluginInterface::VALID_HOOKS)) {
+            throw new InvalidArgumentException("The `{$hook}` hook is not a known plugin hook.");
+        }
+        foreach ($this as $plugin) {
+            if ($plugin->isEnabled($hook)) {
+                yield $plugin;
+            }
+        }
+    }
+}

+ 119 - 0
src/Core/PluginInterface.php

@@ -0,0 +1,119 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright 2005-2011, Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.6.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Core;
+
+use Cake\Event\EventManagerInterface;
+
+/**
+ * Plugin Interface
+ */
+interface PluginInterface
+{
+    /**
+     * List of valid hooks.
+     */
+    const VALID_HOOKS = ['routes', 'bootstrap', 'console', 'middleware'];
+
+    /**
+     * Get the name of this plugin.
+     *
+     * @return string
+     */
+    public function getName();
+
+    /**
+     * Get the filesystem path to this plugin
+     *
+     * @return string
+     */
+    public function getPath();
+
+    /**
+     * Get the filesystem path to configuration for this plugin
+     *
+     * @return string
+     */
+    public function getConfigPath();
+
+    /**
+     * Get the filesystem path to configuration for this plugin
+     *
+     * @return string
+     */
+    public function getClassPath();
+
+    /**
+     * Load all the application configuration and bootstrap logic.
+     *
+     * The default implementation of this method will include the `config/bootstrap.php` in the plugin if it exist. You
+     * can override this method to replace that behavior.
+     *
+     * The host application is provided as an argument. This allows you to load additional
+     * plugin dependencies, or attach events.
+     *
+     * @param \Cake\Core\PluginApplicationInterface $app The host application
+     * @return void
+     */
+    public function bootstrap(PluginApplicationInterface $app);
+
+    /**
+     * Add console commands for the plugin.
+     *
+     * @param \Cake\Console\CommandCollection $commands The command collection to update
+     * @return \Cake\Console\CommandCollection
+     */
+    public function console($commands);
+
+    /**
+     * Add middleware for the plugin.
+     *
+     * @param \Cake\Http\MiddlewareQueue $middleware The middleware queue to update.
+     * @return \Cake\Http\MiddlewareQueue
+     */
+    public function middleware($middleware);
+
+    /**
+     * Add routes for the plugin.
+     *
+     * The default implementation of this method will include the `config/routes.php` in the plugin if it exists. You
+     * can override this method to replace that behavior.
+     *
+     * @param \Cake\Routing\RouteBuilder $routes The route builder to update.
+     * @return \Cake\Routing\RouteBuilder
+     */
+    public function routes($routes);
+
+    /**
+     * Disables the named hook
+     *
+     * @param string $hook The hook to disable
+     * @return $this
+     */
+    public function disable($hook);
+
+    /**
+     * Enables the named hook
+     *
+     * @param string $hook The hook to disable
+     * @return $this
+     */
+    public function enable($hook);
+
+    /**
+     * Check if the named hook is enabled
+     *
+     * @param string $hook The hook to check
+     * @return bool
+     */
+    public function isEnabled($hook);
+}

+ 3 - 0
src/Core/composer.json

@@ -25,6 +25,9 @@
         "php": ">=5.6.0",
         "cakephp/utility": "^3.0.0"
     },
+    "suggest": {
+        "cakephp/event": "To use PluginApplicationInterface or plugin applications."
+    },
     "autoload": {
         "psr-4": {
             "Cake\\Core\\": "."

+ 0 - 26
src/Event/EventApplicationInterface.php

@@ -1,26 +0,0 @@
-<?php
-/**
- * CakePHP(tm) : Rapid Development Framework (http://cakephp.org)
- * Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * Licensed under The MIT License
- * For full copyright and license information, please see the LICENSE.txt
- * Redistributions of files must retain the above copyright notice.
- *
- * @copyright     Copyright (c) Cake Software Foundation, Inc. (http://cakefoundation.org)
- * @link          http://cakephp.org CakePHP(tm) Project
- * @since         3.6.0
- * @license       http://www.opensource.org/licenses/mit-license.php MIT License
- */
-namespace Cake\Event;
-
-interface EventApplicationInterface
-{
-
-    /**
-     * Application hook for attaching events to the Application event manager instance.
-     *
-     * @param \Cake\Event\EventManagerInterface $eventManager Event manager instance.
-     * @return \Cake\Event\EventManagerInterface
-     */
-    public function events(EventManagerInterface $eventManager);
-}

+ 104 - 5
src/Http/BaseApplication.php

@@ -16,13 +16,15 @@ namespace Cake\Http;
 
 use Cake\Core\ConsoleApplicationInterface;
 use Cake\Core\HttpApplicationInterface;
-use Cake\Event\EventApplicationInterface;
-use Cake\Event\EventDispatcherInterface;
+use Cake\Core\Plugin;
+use Cake\Core\PluginApplicationInterface;
+use Cake\Core\PluginInterface;
 use Cake\Event\EventDispatcherTrait;
 use Cake\Event\EventManager;
 use Cake\Event\EventManagerInterface;
 use Cake\Routing\DispatcherFactory;
 use Cake\Routing\Router;
+use InvalidArgumentException;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 
@@ -33,7 +35,10 @@ use Psr\Http\Message\ServerRequestInterface;
  * and ensuring that middleware is attached. It is also invoked as the last piece
  * of middleware, and delegates request/response handling to the correct controller.
  */
-abstract class BaseApplication implements ConsoleApplicationInterface, HttpApplicationInterface, EventApplicationInterface, EventDispatcherInterface
+abstract class BaseApplication implements
+    ConsoleApplicationInterface,
+    HttpApplicationInterface,
+    PluginApplicationInterface
 {
 
     use EventDispatcherTrait;
@@ -44,6 +49,13 @@ abstract class BaseApplication implements ConsoleApplicationInterface, HttpAppli
     protected $configDir;
 
     /**
+     * Plugin Collection
+     *
+     * @var \Cake\Core\PluginCollection
+     */
+    protected $plugins;
+
+    /**
      * Constructor
      *
      * @param string $configDir The directory the bootstrap configuration is held in.
@@ -52,6 +64,7 @@ abstract class BaseApplication implements ConsoleApplicationInterface, HttpAppli
     public function __construct($configDir, EventManagerInterface $eventManager = null)
     {
         $this->configDir = $configDir;
+        $this->plugins = Plugin::getCollection();
         $this->_eventManager = $eventManager ?: EventManager::instance();
     }
 
@@ -64,6 +77,66 @@ abstract class BaseApplication implements ConsoleApplicationInterface, HttpAppli
     /**
      * {@inheritDoc}
      */
+    public function pluginMiddleware($middleware)
+    {
+        foreach ($this->plugins->with('middleware') as $plugin) {
+            $middleware = $plugin->middleware($middleware);
+        }
+
+        return $middleware;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function addPlugin($name, array $config = [])
+    {
+        if (is_string($name)) {
+            $plugin = $this->makePlugin($name, $config);
+        } else {
+            $plugin = $name;
+        }
+        $this->plugins->add($plugin);
+
+        return $this;
+    }
+
+    /**
+     * Get the plugin collection in use.
+     *
+     * @return \Cake\Core\PluginCollection
+     */
+    public function getPlugins()
+    {
+        return $this->plugins;
+    }
+
+    /**
+     * Create a plugin instance from a classname and configuration
+     *
+     * @param string $name The plugin classname
+     * @param array $config Configuration options for the plugin
+     * @return \Cake\Core\PluginInterface
+     */
+    public function makePlugin($name, array $config)
+    {
+        if (strpos($name, '\\') === false) {
+            $name = str_replace('/', '\\', $name) . '\\' . 'Plugin';
+        }
+        if (!class_exists($name)) {
+            throw new InvalidArgumentException("The `{$name}` plugin cannot be found");
+        }
+        $plugin = new $name($config);
+        if (!$plugin instanceof PluginInterface) {
+            throw new InvalidArgumentException("The `{$name}` plugin does not implement Cake\Core\PluginInterface.");
+        }
+
+        return $plugin;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
     public function bootstrap()
     {
         require_once $this->configDir . '/bootstrap.php';
@@ -71,6 +144,16 @@ abstract class BaseApplication implements ConsoleApplicationInterface, HttpAppli
 
     /**
      * {@inheritDoc}
+     */
+    public function pluginBootstrap()
+    {
+        foreach ($this->plugins->with('bootstrap') as $plugin) {
+            $plugin->bootstrap($this);
+        }
+    }
+
+    /**
+     * {@inheritDoc}
      *
      * By default this will load `config/routes.php` for ease of use and backwards compatibility.
      *
@@ -87,6 +170,18 @@ abstract class BaseApplication implements ConsoleApplicationInterface, HttpAppli
     }
 
     /**
+     * {@inheritDoc}
+     */
+    public function pluginRoutes($routes)
+    {
+        foreach ($this->plugins->with('routes') as $plugin) {
+            $plugin->routes($routes);
+        }
+
+        return $routes;
+    }
+
+    /**
      * Define the console commands for an application.
      *
      * By default all commands in CakePHP, plugins and the application will be
@@ -103,9 +198,13 @@ abstract class BaseApplication implements ConsoleApplicationInterface, HttpAppli
     /**
      * {@inheritDoc}
      */
-    public function events(EventManagerInterface $eventManager)
+    public function pluginConsole($commands)
     {
-        return $eventManager;
+        foreach ($this->plugins->with('console') as $plugin) {
+            $commands = $plugin->console($commands);
+        }
+
+        return $commands;
     }
 
     /**

+ 73 - 22
src/Http/Server.php

@@ -15,9 +15,11 @@
 namespace Cake\Http;
 
 use Cake\Core\HttpApplicationInterface;
-use Cake\Event\EventApplicationInterface;
+use Cake\Core\PluginApplicationInterface;
 use Cake\Event\EventDispatcherInterface;
 use Cake\Event\EventDispatcherTrait;
+use Cake\Event\EventManager;
+use InvalidArgumentException;
 use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use RuntimeException;
@@ -29,7 +31,14 @@ use Zend\Diactoros\Response\EmitterInterface;
 class Server implements EventDispatcherInterface
 {
 
-    use EventDispatcherTrait;
+    /**
+     * Alias methods away so we can implement proxying methods.
+     */
+    use EventDispatcherTrait {
+        eventManager as private _eventManager;
+        getEventManager as private _getEventManager;
+        setEventManager as private _setEventManager;
+    }
 
     /**
      * @var \Cake\Core\HttpApplicationInterface
@@ -48,7 +57,7 @@ class Server implements EventDispatcherInterface
      */
     public function __construct(HttpApplicationInterface $app)
     {
-        $this->setApp($app);
+        $this->app = $app;
         $this->setRunner(new Runner());
     }
 
@@ -76,11 +85,16 @@ class Server implements EventDispatcherInterface
         $request = $request ?: ServerRequestFactory::fromGlobals();
 
         $middleware = $this->app->middleware(new MiddlewareQueue());
+        if ($this->app instanceof PluginApplicationInterface) {
+            $middleware = $this->app->pluginMiddleware($middleware);
+        }
+
         if (!($middleware instanceof MiddlewareQueue)) {
             throw new RuntimeException('The application `middleware` method did not return a middleware queue.');
         }
         $this->dispatchEvent('Server.buildMiddleware', ['middleware' => $middleware]);
         $middleware->add($this->app);
+
         $response = $this->runner->run($middleware, $request, $response);
 
         if (!($response instanceof ResponseInterface)) {
@@ -97,15 +111,17 @@ class Server implements EventDispatcherInterface
      * Application bootstrap wrapper.
      *
      * Calls `bootstrap()` and `events()` if application implements `EventApplicationInterface`.
+     * After the application is bootstrapped and events are attached, plugins are bootstrapped
+     * and have their events attached.
      *
      * @return void
      */
     protected function bootstrap()
     {
         $this->app->bootstrap();
-        if ($this->app instanceof EventApplicationInterface) {
-            $eventManager = $this->app->events($this->getEventManager());
-            $this->setEventManager($eventManager);
+
+        if ($this->app instanceof PluginApplicationInterface) {
+            $this->app->pluginBootstrap();
         }
     }
 
@@ -126,42 +142,77 @@ class Server implements EventDispatcherInterface
     }
 
     /**
-     * Set the application.
+     * Get the current application.
      *
-     * @param \Cake\Core\HttpApplicationInterface $app The application to set.
+     * @return \Cake\Core\HttpApplicationInterface The application that will be run.
+     */
+    public function getApp()
+    {
+        return $this->app;
+    }
+
+    /**
+     * Set the runner
+     *
+     * @param \Cake\Http\Runner $runner The runner to use.
      * @return $this
      */
-    public function setApp(HttpApplicationInterface $app)
+    public function setRunner(Runner $runner)
     {
-        $this->app = $app;
+        $this->runner = $runner;
 
-        if ($app instanceof EventDispatcherInterface) {
-            $this->setEventManager($app->getEventManager());
+        return $this;
+    }
+
+    /**
+     * Get the application's event manager or the global one.
+     *
+     * @return \Cake\Event\EventManagerInterface
+     */
+    public function getEventManager()
+    {
+        if ($this->app instanceof PluginApplicationInterface) {
+            return $this->app->getEventManager();
         }
 
-        return $this;
+        return EventManager::instance();
     }
 
     /**
-     * Get the current application.
+     * Get/set the application's event manager.
      *
-     * @return \Cake\Core\HttpApplicationInterface The application that will be run.
+     * If the application does not support events and this method is used as
+     * a setter, an exception will be raised.
+     *
+     * @param \Cake\Event\EventManager|null $events The event manager to set.
+     * @return \Cake\Event\EventManager|$this
+     * @deprecated 3.6.0 Will be removed in 4.0
      */
-    public function getApp()
+    public function eventManager(EventManager $events = null)
     {
-        return $this->app;
+        deprecationWarning('eventManager() is deprecated. Use getEventManager()/setEventManager() instead.');
+        if ($events === null) {
+            return $this->getEventManager();
+        }
+
+        return $this->setEventManager($events);
     }
 
     /**
-     * Set the runner
+     * Get/set the application's event manager.
      *
-     * @param \Cake\Http\Runner $runner The runner to use.
+     * If the application does not support events and this method is used as
+     * a setter, an exception will be raised.
+     *
+     * @param \Cake\Event\EventManager $events The event manager to set.
      * @return $this
      */
-    public function setRunner(Runner $runner)
+    public function setEventManager(EventManager $events)
     {
-        $this->runner = $runner;
+        if ($this->app instanceof PluginApplicationInterface) {
+            return $this->app->setEventManager($events);
+        }
 
-        return $this;
+        throw new InvalidArgumentException('Cannot set the event manager, the application does not support events.');
     }
 }

+ 8 - 3
src/Routing/Middleware/RoutingMiddleware.php

@@ -14,6 +14,7 @@
  */
 namespace Cake\Routing\Middleware;
 
+use Cake\Core\PluginApplicationInterface;
 use Cake\Http\BaseApplication;
 use Cake\Http\MiddlewareQueue;
 use Cake\Http\Runner;
@@ -56,9 +57,13 @@ class RoutingMiddleware
      */
     protected function loadRoutes()
     {
-        if ($this->app) {
-            $builder = Router::createRouteBuilder('/');
-            $this->app->routes($builder);
+        if (!$this->app) {
+            return;
+        }
+        $builder = Router::createRouteBuilder('/');
+        $this->app->routes($builder);
+        if ($this->app instanceof PluginApplicationInterface) {
+            $this->app->pluginRoutes($builder);
         }
     }
 

+ 80 - 35
tests/TestCase/Console/CommandRunnerTest.php

@@ -19,11 +19,13 @@ use Cake\Console\CommandRunner;
 use Cake\Console\ConsoleIo;
 use Cake\Console\Shell;
 use Cake\Core\Configure;
+use Cake\Core\ConsoleApplicationInterface;
 use Cake\Event\EventList;
 use Cake\Event\EventManager;
 use Cake\Http\BaseApplication;
 use Cake\TestSuite\Stub\ConsoleOutput;
 use Cake\TestSuite\TestCase;
+use InvalidArgumentException;
 use TestApp\Command\DemoCommand;
 use TestApp\Http\EventApplication;
 use TestApp\Shell\SampleShell;
@@ -53,25 +55,65 @@ class CommandRunnerTest extends TestCase
     }
 
     /**
-     * test set on the app
+     * test event manager proxies to the application.
      *
      * @return void
      */
-    public function testSetApp()
+    public function testEventManagerProxies()
     {
-        $app = $this->getMockBuilder(BaseApplication::class)
-            ->setConstructorArgs([$this->config])
-            ->getMock();
-
-        $manager = new EventManager();
-        $app->method('getEventManager')
-            ->willReturn($manager);
+        $app = $this->getMockForAbstractClass(
+            BaseApplication::class,
+            [$this->config]
+        );
 
         $runner = new CommandRunner($app);
         $this->assertSame($app->getEventManager(), $runner->getEventManager());
     }
 
     /**
+     * test event manager cannot be set on applications without events.
+     *
+     * @return void
+     */
+    public function testGetEventManagerNonEventedApplication()
+    {
+        $app = $this->createMock(ConsoleApplicationInterface::class);
+
+        $runner = new CommandRunner($app);
+        $this->assertSame(EventManager::instance(), $runner->getEventManager());
+    }
+
+    /**
+     * test event manager cannot be set on applications without events.
+     *
+     * @return void
+     */
+    public function testSetEventManagerNonEventedApplication()
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $app = $this->createMock(ConsoleApplicationInterface::class);
+
+        $events = new EventManager();
+        $runner = new CommandRunner($app);
+        $runner->setEventManager($events);
+    }
+
+    /**
+     * test deprecated method defined in interface
+     *
+     * @return void
+     */
+    public function testEventManagerCompat()
+    {
+        $this->deprecated(function () {
+            $app = $this->createMock(ConsoleApplicationInterface::class);
+
+            $runner = new CommandRunner($app);
+            $this->assertSame(EventManager::instance(), $runner->eventManager());
+        });
+    }
+
+    /**
      * Test that the console hook not returning a command collection
      * raises an error.
      *
@@ -320,32 +362,6 @@ class CommandRunnerTest extends TestCase
     }
 
     /**
-     * Test running a valid command
-     *
-     * @return void
-     */
-    public function testRunEvents()
-    {
-        $app = new EventApplication($this->config);
-        $output = new ConsoleOutput();
-
-        $manager = $app->getEventManager();
-        $manager->setEventList(new EventList());
-
-        $runner = new CommandRunner($app, 'cake');
-        $this->assertSame($manager, $runner->getEventManager());
-
-        $result = $runner->run(['cake', 'ex'], $this->getMockIo($output));
-        $this->assertSame(Shell::CODE_SUCCESS, $result);
-
-        $messages = implode("\n", $output->messages());
-        $this->assertContains('Demo Command!', $messages);
-
-        $this->assertCount(1, $manager->listeners('My.event'));
-        $this->assertEventFired('Console.buildCommands', $manager);
-    }
-
-    /**
      * Test running a command class' help
      *
      * @return void
@@ -386,6 +402,35 @@ class CommandRunnerTest extends TestCase
         $this->assertTrue($this->eventTriggered, 'Should have triggered event.');
     }
 
+    /**
+     * Test that run calls plugin hook methods
+     *
+     * @return void
+     */
+    public function testRunCallsPluginHookMethods()
+    {
+        $app = $this->getMockBuilder(BaseApplication::class)
+            ->setMethods(['middleware', 'bootstrap', 'pluginBootstrap', 'pluginEvents', 'pluginConsole'])
+            ->setConstructorArgs([$this->config])
+            ->getMock();
+
+        $app->expects($this->at(0))->method('bootstrap');
+        $app->expects($this->at(1))->method('pluginBootstrap');
+
+        $commands = new CommandCollection();
+        $app->expects($this->at(2))
+            ->method('pluginConsole')
+            ->with($this->isinstanceOf(CommandCollection::class))
+            ->will($this->returnCallback(function ($commands) {
+                return $commands;
+            }));
+
+        $output = new ConsoleOutput();
+        $runner = new CommandRunner($app, 'cake');
+        $result = $runner->run(['cake', '--version'], $this->getMockIo($output));
+        $this->assertContains(Configure::version(), $output->messages()[0]);
+    }
+
     protected function makeAppWithCommands($commands)
     {
         $app = $this->getMockBuilder(BaseApplication::class)

+ 142 - 0
tests/TestCase/Core/BasePluginTest.php

@@ -0,0 +1,142 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.6.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Core;
+
+use Cake\Console\CommandCollection;
+use Cake\Core\BasePlugin;
+use Cake\Core\Configure;
+use Cake\Core\Plugin;
+use Cake\Core\PluginApplicationInterface;
+use Cake\Event\EventManager;
+use Cake\Http\MiddlewareQueue;
+use Cake\TestSuite\TestCase;
+use Company\TestPluginThree\Plugin as TestPluginThree;
+use TestPlugin\Plugin as TestPlugin;
+
+/**
+ * BasePluginTest class
+ */
+class BasePluginTest extends TestCase
+{
+
+    /**
+     * tearDown method
+     *
+     * @return void
+     */
+    public function tearDown()
+    {
+        parent::tearDown();
+        Plugin::unload();
+    }
+
+    /**
+     * testConfigForRoutesAndBootstrap
+     *
+     * @return void
+     */
+    public function testConfigForRoutesAndBootstrap()
+    {
+        $plugin = new BasePlugin([
+            'bootstrap' => false,
+            'routes' => false
+        ]);
+
+        $this->assertFalse($plugin->isEnabled('routes'));
+        $this->assertFalse($plugin->isEnabled('bootstrap'));
+        $this->assertTrue($plugin->isEnabled('console'));
+        $this->assertTrue($plugin->isEnabled('middleware'));
+    }
+
+    public function testGetName()
+    {
+        $plugin = new TestPlugin();
+        $this->assertSame('TestPlugin', $plugin->getName());
+
+        $plugin = new TestPluginThree();
+        $this->assertSame('Company/TestPluginThree', $plugin->getName());
+    }
+
+    public function testGetNameOption()
+    {
+        $plugin = new TestPlugin(['name' => 'Elephants']);
+        $this->assertSame('Elephants', $plugin->getName());
+    }
+
+    public function testMiddleware()
+    {
+        $plugin = new BasePlugin();
+        $middleware = new MiddlewareQueue();
+        $this->assertSame($middleware, $plugin->middleware($middleware));
+    }
+
+    public function testConsole()
+    {
+        $plugin = new BasePlugin();
+        $commands = new CommandCollection();
+        $this->assertSame($commands, $plugin->console($commands));
+    }
+
+    public function testBootstrap()
+    {
+        $app = $this->createMock(PluginApplicationInterface::class);
+        $plugin = new TestPlugin();
+
+        $this->assertFalse(Configure::check('PluginTest.test_plugin.bootstrap'));
+        $this->assertNull($plugin->bootstrap($app));
+        $this->assertTrue(Configure::check('PluginTest.test_plugin.bootstrap'));
+    }
+
+    public function testConstructorArguments()
+    {
+        $plugin = new BasePlugin([
+            'routes' => false,
+            'bootstrap' => false,
+            'console' => false,
+            'middleware' => false
+        ]);
+        $this->assertFalse($plugin->isEnabled('routes'));
+        $this->assertFalse($plugin->isEnabled('bootstrap'));
+        $this->assertFalse($plugin->isEnabled('console'));
+        $this->assertFalse($plugin->isEnabled('middleware'));
+    }
+
+    public function testGetPathBaseClass()
+    {
+        $plugin = new BasePlugin();
+
+        $expected = CAKE . 'Core' . DS;
+        $this->assertSame($expected, $plugin->getPath());
+        $this->assertSame($expected . 'config' . DS, $plugin->getConfigPath());
+        $this->assertSame($expected . 'src' . DS, $plugin->getClassPath());
+    }
+
+    public function testGetPathOptionValue()
+    {
+        $plugin = new BasePlugin(['path' => '/some/path']);
+        $expected = '/some/path';
+        $this->assertSame($expected, $plugin->getPath());
+        $this->assertSame($expected . 'config' . DS, $plugin->getConfigPath());
+        $this->assertSame($expected . 'src' . DS, $plugin->getClassPath());
+    }
+
+    public function testGetPathSubclass()
+    {
+        $plugin = new TestPlugin();
+        $expected = TEST_APP . 'Plugin/TestPlugin' . DS;
+        $this->assertSame($expected, $plugin->getPath());
+        $this->assertSame($expected . 'config' . DS, $plugin->getConfigPath());
+        $this->assertSame($expected . 'src' . DS, $plugin->getClassPath());
+    }
+}

+ 140 - 0
tests/TestCase/Core/PluginCollectionTest.php

@@ -0,0 +1,140 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.6.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Core;
+
+use Cake\Core\Exception\MissingPluginException;
+use Cake\Core\PluginCollection;
+use Cake\Core\PluginInterface;
+use Cake\TestSuite\TestCase;
+use Company\TestPluginThree\Plugin as TestPluginThree;
+use InvalidArgumentException;
+use TestPlugin\Plugin as TestPlugin;
+
+/**
+ * PluginCollection Test
+ */
+class PluginCollectionTest extends TestCase
+{
+    public function testConstructor()
+    {
+        $plugins = new PluginCollection([new TestPlugin()]);
+
+        $this->assertCount(1, $plugins);
+        $this->assertTrue($plugins->has('TestPlugin'));
+    }
+
+    public function testAdd()
+    {
+        $plugins = new PluginCollection();
+        $this->assertCount(0, $plugins);
+
+        $plugins->add(new TestPlugin());
+        $this->assertCount(1, $plugins);
+    }
+
+    public function testAddOperations()
+    {
+        $plugins = new PluginCollection();
+        $plugins->add(new TestPlugin());
+
+        $this->assertFalse($plugins->has('Nope'));
+        $this->assertSame($plugins, $plugins->remove('Nope'));
+
+        $this->assertTrue($plugins->has('TestPlugin'));
+        $this->assertSame($plugins, $plugins->remove('TestPlugin'));
+        $this->assertCount(0, $plugins);
+        $this->assertFalse($plugins->has('TestPlugin'));
+    }
+
+    public function testAddVendoredPlugin()
+    {
+        $plugins = new PluginCollection();
+        $plugins->add(new TestPluginThree());
+
+        $this->assertTrue($plugins->has('Company/TestPluginThree'));
+        $this->assertFalse($plugins->has('TestPluginThree'));
+        $this->assertFalse($plugins->has('Company'));
+        $this->assertFalse($plugins->has('TestPlugin'));
+    }
+
+    public function testHas()
+    {
+        $plugins = new PluginCollection();
+        $this->assertFalse($plugins->has('TestPlugin'));
+
+        $plugins->add(new TestPlugin());
+        $this->assertTrue($plugins->has('TestPlugin'));
+        $this->assertFalse($plugins->has('Plugin'));
+    }
+
+    public function testGet()
+    {
+        $plugins = new PluginCollection();
+        $plugin = new TestPlugin();
+        $plugins->add($plugin);
+
+        $this->assertSame($plugin, $plugins->get('TestPlugin'));
+    }
+
+    public function testGetInvalid()
+    {
+        $this->expectException(MissingPluginException::class);
+
+        $plugins = new PluginCollection();
+        $plugins->get('Invalid');
+    }
+
+    public function testIterator()
+    {
+        $data = [
+            new TestPlugin(),
+            new TestPluginThree()
+        ];
+        $plugins = new PluginCollection($data);
+        $out = [];
+        foreach ($plugins as $key => $plugin) {
+            $this->assertInstanceOf(PluginInterface::class, $plugin);
+            $out[] = $plugin;
+        }
+        $this->assertSame($data, $out);
+    }
+
+    public function testWith()
+    {
+        $plugins = new PluginCollection();
+        $plugin = new TestPlugin();
+        $plugin->disable('routes');
+
+        $pluginThree = new TestPluginThree();
+
+        $plugins->add($plugin);
+        $plugins->add($pluginThree);
+
+        $out = [];
+        foreach ($plugins->with('routes') as $p) {
+            $out[] = $p;
+        }
+        $this->assertCount(1, $out);
+        $this->assertSame($pluginThree, $out[0]);
+    }
+
+    public function testWithInvalidHook()
+    {
+        $this->expectException(InvalidArgumentException::class);
+
+        $plugins = new PluginCollection();
+        foreach ($plugins->with('bad') as $p) {
+        }
+    }
+}

+ 0 - 12
tests/TestCase/Core/PluginTest.php

@@ -195,18 +195,6 @@ class PluginTest extends TestCase
     }
 
     /**
-     * Tests that loading a missing routes file throws a warning
-     *
-     * @return void
-     */
-    public function testLoadMultipleWithDefaultsMissingFile()
-    {
-        $this->expectException(\PHPUnit\Framework\Error\Warning::class);
-        Plugin::load(['TestPlugin', 'TestPluginTwo'], ['bootstrap' => true, 'routes' => true]);
-        Plugin::routes();
-    }
-
-    /**
      * Test ignoring missing bootstrap/routes file
      *
      * @return void

+ 116 - 3
tests/TestCase/Http/BaseApplicationTest.php

@@ -1,9 +1,30 @@
 <?php
-namespace Cake\Test\TestCase;
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.5.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Http;
 
+use Cake\Core\Configure;
+use Cake\Core\Plugin;
+use Cake\Http\BaseApplication;
+use Cake\Http\MiddlewareQueue;
 use Cake\Http\Response;
 use Cake\Http\ServerRequestFactory;
+use Cake\Routing\RouteBuilder;
+use Cake\Routing\RouteCollection;
 use Cake\TestSuite\TestCase;
+use InvalidArgumentException;
+use TestPlugin\Plugin as TestPlugin;
 
 /**
  * Base application test.
@@ -19,6 +40,13 @@ class BaseApplicationTest extends TestCase
     {
         parent::setUp();
         static::setAppNamespace();
+        $this->path = dirname(dirname(__DIR__));
+    }
+
+    public function tearDown()
+    {
+        parent::tearDown();
+        Plugin::unload();
     }
 
     /**
@@ -40,10 +68,95 @@ class BaseApplicationTest extends TestCase
             'pass' => []
         ]);
 
-        $path = dirname(dirname(__DIR__));
-        $app = $this->getMockForAbstractClass('Cake\Http\BaseApplication', [$path]);
+        $app = $this->getMockForAbstractClass('Cake\Http\BaseApplication', [$this->path]);
         $result = $app($request, $response, $next);
         $this->assertInstanceOf('Psr\Http\Message\ResponseInterface', $result);
         $this->assertEquals('Hello Jane', '' . $result->getBody());
     }
+
+    public function testAddPluginUnknownClass()
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('cannot be found');
+        $app = $this->getMockForAbstractClass(BaseApplication::class, [$this->path]);
+        $app->addPlugin('SomethingBad');
+    }
+
+    public function testAddPluginBadClass()
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->expectExceptionMessage('does not implement');
+        $app = $this->getMockForAbstractClass(BaseApplication::class, [$this->path]);
+        $app->addPlugin(__CLASS__);
+    }
+
+    public function testAddPluginValidShortName()
+    {
+        $app = $this->getMockForAbstractClass(BaseApplication::class, [$this->path]);
+        $app->addPlugin('TestPlugin');
+
+        $this->assertCount(1, $app->getPlugins());
+        $this->assertTrue($app->getPlugins()->has('TestPlugin'));
+
+        $app->addPlugin('Company/TestPluginThree');
+        $this->assertCount(2, $app->getPlugins());
+        $this->assertTrue($app->getPlugins()->has('Company/TestPluginThree'));
+    }
+
+    public function testAddPluginValid()
+    {
+        $app = $this->getMockForAbstractClass(BaseApplication::class, [$this->path]);
+        $app->addPlugin(TestPlugin::class);
+
+        $this->assertCount(1, $app->getPlugins());
+        $this->assertTrue($app->getPlugins()->has('TestPlugin'));
+    }
+
+    public function testPluginMiddleware()
+    {
+        $start = new MiddlewareQueue();
+        $app = $this->getMockForAbstractClass(
+            BaseApplication::class,
+            [$this->path]
+        );
+        $app->addPlugin(TestPlugin::class);
+
+        $after = $app->pluginMiddleware($start);
+        $this->assertSame($start, $after);
+        $this->assertCount(1, $after);
+    }
+
+    public function testPluginRoutes()
+    {
+        $collection = new RouteCollection();
+        $routes = new RouteBuilder($collection, '/');
+        $app = $this->getMockForAbstractClass(
+            BaseApplication::class,
+            [$this->path]
+        );
+        $app->addPlugin(TestPlugin::class);
+
+        $result = $app->pluginRoutes($routes);
+        $this->assertSame($routes, $result);
+        $url = [
+            'plugin' => 'TestPlugin',
+            'controller' => 'TestPlugin',
+            'action' => 'index',
+            '_method' => 'GET'
+        ];
+        $this->assertNotEmpty($collection->match($url, []));
+    }
+
+    public function testPluginBootstrap()
+    {
+        $app = $this->getMockForAbstractClass(
+            BaseApplication::class,
+            [$this->path]
+        );
+        $app->addPlugin(TestPlugin::class);
+
+        $this->assertFalse(Configure::check('PluginTest.test_plugin.bootstrap'));
+        $this->assertNull($app->pluginBootstrap());
+        $this->assertTrue(Configure::check('PluginTest.test_plugin.bootstrap'));
+    }
 }

+ 101 - 19
tests/TestCase/Http/ServerTest.php

@@ -14,12 +14,16 @@
  */
 namespace Cake\Test\TestCase;
 
+use Cake\Core\HttpApplicationInterface;
 use Cake\Event\Event;
 use Cake\Event\EventList;
 use Cake\Event\EventManager;
+use Cake\Http\BaseApplication;
 use Cake\Http\CallbackStream;
+use Cake\Http\MiddlewareQueue;
 use Cake\Http\Server;
 use Cake\TestSuite\TestCase;
+use InvalidArgumentException;
 use Psr\Http\Message\ResponseInterface;
 use RuntimeException;
 use TestApp\Http\BadResponseApplication;
@@ -109,6 +113,44 @@ class ServerTest extends TestCase
     }
 
     /**
+     * test run calling plugin hooks
+     *
+     * @return void
+     */
+    public function testRunCallingPluginHooks()
+    {
+        $response = new Response('php://memory', 200, ['X-testing' => 'source header']);
+        $request = ServerRequestFactory::fromGlobals();
+        $request = $request->withHeader('X-pass', 'request header');
+
+        $app = $this->getMockBuilder(MiddlewareApplication::class)
+            ->setMethods(['pluginBootstrap', 'pluginEvents', 'pluginMiddleware'])
+            ->setConstructorArgs([$this->config])
+            ->getMock();
+        $app->expects($this->at(0))
+            ->method('pluginBootstrap');
+        $app->expects($this->at(1))
+            ->method('pluginMiddleware')
+            ->with($this->isInstanceOf(MiddlewareQueue::class))
+            ->will($this->returnCallback(function ($middleware) {
+                return $middleware;
+            }));
+
+        $server = new Server($app);
+        $res = $server->run($request, $response);
+        $this->assertEquals(
+            'source header',
+            $res->getHeaderLine('X-testing'),
+            'Input response is carried through out middleware'
+        );
+        $this->assertEquals(
+            'request header',
+            $res->getHeaderLine('X-pass'),
+            'Request is used in middleware'
+        );
+    }
+
+    /**
      * test run building a request from globals.
      *
      * @return void
@@ -171,25 +213,6 @@ class ServerTest extends TestCase
     }
 
     /**
-     * Test application events.
-     *
-     * @return void
-     */
-    public function testRunEvents()
-    {
-        $manager = new EventManager();
-        $manager->setEventList(new EventList());
-        $app = new EventApplication($this->config, $manager);
-
-        $server = new Server($app);
-        $res = $server->run();
-
-        $this->assertCount(1, $manager->listeners('My.event'));
-        $this->assertEventFired('Server.buildMiddleware', $manager);
-        $this->assertInstanceOf(ResponseInterface::class, $res);
-    }
-
-    /**
      * Test that emit invokes the appropriate methods on the emitter.
      *
      * @return void
@@ -257,4 +280,63 @@ class ServerTest extends TestCase
         $this->assertInstanceOf('Closure', $this->middleware->get(3), '2nd last middleware is a closure');
         $this->assertSame($app, $this->middleware->get(4), 'Last middleware is an app instance');
     }
+
+    /**
+     * test event manager proxies to the application.
+     *
+     * @return void
+     */
+    public function testEventManagerProxies()
+    {
+        $app = $this->getMockForAbstractClass(
+            BaseApplication::class,
+            [$this->config]
+        );
+
+        $server = new Server($app);
+        $this->assertSame($app->getEventManager(), $server->getEventManager());
+    }
+
+    /**
+     * test event manager cannot be set on applications without events.
+     *
+     * @return void
+     */
+    public function testGetEventManagerNonEventedApplication()
+    {
+        $app = $this->createMock(HttpApplicationInterface::class);
+
+        $server = new Server($app);
+        $this->assertSame(EventManager::instance(), $server->getEventManager());
+    }
+
+    /**
+     * test event manager cannot be set on applications without events.
+     *
+     * @return void
+     */
+    public function testSetEventManagerNonEventedApplication()
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $app = $this->createMock(HttpApplicationInterface::class);
+
+        $events = new EventManager();
+        $server = new Server($app);
+        $server->setEventManager($events);
+    }
+
+    /**
+     * test deprecated method defined in interface
+     *
+     * @return void
+     */
+    public function testEventManagerCompat()
+    {
+        $this->deprecated(function () {
+            $app = $this->createMock(HttpApplicationInterface::class);
+
+            $server = new Server($app);
+            $this->assertSame(EventManager::instance(), $server->eventManager());
+        });
+    }
 }

+ 27 - 0
tests/TestCase/Routing/Middleware/RoutingMiddlewareTest.php

@@ -15,6 +15,7 @@
 namespace Cake\Test\TestCase\Routing\Middleware;
 
 use Cake\Routing\Middleware\RoutingMiddleware;
+use Cake\Routing\RouteBuilder;
 use Cake\Routing\Router;
 use Cake\TestSuite\TestCase;
 use TestApp\Application;
@@ -166,6 +167,32 @@ class RoutingMiddlewareTest extends TestCase
     }
 
     /**
+     * Test that pluginRoutes hook is called
+     *
+     * @return void
+     */
+    public function testRoutesHookCallsPluginHook()
+    {
+        Router::reload();
+        $this->assertFalse(Router::$initialized, 'Router precondition failed');
+
+        $request = ServerRequestFactory::fromGlobals(['REQUEST_URI' => '/app/articles']);
+        $response = new Response();
+        $next = function ($req, $res) {
+            return $res;
+        };
+        $app = $this->getMockBuilder(Application::class)
+            ->setMethods(['pluginRoutes'])
+            ->setConstructorArgs([CONFIG])
+            ->getMock();
+        $app->expects($this->once())
+            ->method('pluginRoutes')
+            ->with($this->isInstanceOf(RouteBuilder::class));
+        $middleware = new RoutingMiddleware($app);
+        $middleware($request, $response, $next);
+    }
+
+    /**
      * Test that routing is not applied if a controller exists already
      *
      * @return void

+ 21 - 0
tests/test_app/Plugin/Company/TestPluginFive/src/Plugin.php

@@ -0,0 +1,21 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.6.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Company\TestPluginFive;
+
+use Cake\Core\BasePlugin;
+
+class Plugin extends BasePlugin
+{
+
+}

+ 21 - 0
tests/test_app/Plugin/Company/TestPluginThree/src/Plugin.php

@@ -0,0 +1,21 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.6.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Company\TestPluginThree;
+
+use Cake\Core\BasePlugin;
+
+class Plugin extends BasePlugin
+{
+
+}

+ 37 - 0
tests/test_app/Plugin/TestPlugin/src/Plugin.php

@@ -0,0 +1,37 @@
+<?php
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         3.6.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace TestPlugin;
+
+use Cake\Core\BasePlugin;
+use Cake\Event\EventManagerInterface;
+
+class Plugin extends BasePlugin
+{
+    public function events(EventManagerInterface $events)
+    {
+        $events->on('TestPlugin.load', function () {
+        });
+
+        return $events;
+    }
+
+    public function middleware($middleware)
+    {
+        $middleware->add(function ($req, $res, $next) {
+            return $next($req, $res);
+        });
+
+        return $middleware;
+    }
+}