RFC: zend-mvc 4 Design Changes


#1

PSR-7 native zend-mvc

This RFC covers high points of my ongoing effort to convert zend-mvc to PSR-7.

PSR-7 Request and Response

PSR-7 is a way into the future. But what does it mean for zend-mvc? Sadly, immutable nature is incompatible with zend-http and switch to PSR-7 constitutes immense backward compatibility break.
In versions 2 and 3, zend-mvc relies heavily on zend-http request and response mutability, where single instance is expected to be shared between many parts, starting from Zend\Mvc\Application to various listeners, controllers, helpers.

To keep zend-mvc architecture changes to a minimum, mutability is shifted to MvcEvent instance, which then becomes a single source of truth throughout the dispatch. If request or response need to be changed, they should be obtained from MvcEvent, changed and then set back.

Console integration removal

Console and HTTP handling needs are different. Listeners and controllers are targeting one or the other most of the time, not both. In practice, many implementations do not expect to receive console requests at all. Besides, as stated above, PSR-7 request and response are not compatible with zend-stdlib request and response.

Considering all, support for console handling will be dropped from zend-mvc in version 4. That will simplify zend-mvc and allow to better focus on HTTP request handling.

Recommended way forward for framework users is to implement console as a separate application, utilizing shared container. It should be trivial with configuration merging and container setup moved out of zend-mvc.
zf-console is recommended for simple needs and Symfony console for more complex cases.

Depending on Request or Response outside of dispatching

Some zend-mvc users are depending on Request availability during bootstrap or even module loading.
This is flawed approach. Request and Response are runtime values and they are not guaranteed to exist or be the same during bootstrap. With container, service creation can happen outside of application runtime as well. To remove a possibility for their abuse, Request and Response are removed from container and application, and provided only during Zend\Mvc\Application::run() as MvcEvent parameters.
Should request specific initialization be needed, it should happen early on mvc routing event.
Dedicated request setup event is likely to be introduced later specifically for that purpose.

ResponseSender replaced with Diactoros ResponseEmitter

Console removal allows zend-mvc to make use of Diactoros reusable PSR-7 response emitters and drop Zend\Mvc\ResponseSender.
Zend\Mvc\Application now composes emitter as a constructor dependency and invokes it in run(). SendResponseListener is removed from finish event and dropped.

Mvc Application as a PSR-15 RequestHandler

Zend\Mvc\Application implements PSR RequestHandler so it could be used as a final handler, for example in middleware pipelines.
Zend\Mvc\Application::run() delegates to Zend\Mvc\Application::handle() and emits returned Response with the help of ResponseEmitter.
Since handle() must return response and not emit it, SendResponseListener is removed from 'finish` mvc event.

ModuleManager is… gone?

Back in the time, module manager was introduced to solve basic packaging support, autoloading, config loading and merging. Number of important things happened since then.
Composer emerged and became a standard package manager in php world. It solved all our autoloading needs. No longer constrained by autoloading, ConfigAggregator was introduced to handle config loading and merging in a simple and clean way.

Service configuration is available before container is created, Zend\ModuleManager\Listener\ServiceListener for various plugin managers could be replaced by regular factories. That fits well with zend-servicemanager v3 push for immutability.

Module::onBootstrap() is just a convenience method for providing listener for zend-mvc bootstrap event. Zend\Mvc\Application factory is now looks for ListenerAggregates in config which then attached to Application’s EventManager.

Dropping module manager from zend-mvc greatly reduces Application complexity, makes zend-mvc more consistent with zend-expressive and ConfigProvider use in zend framework components.

New mvc application setup would be immediately familiar for zend-expressive users:

// config/config.php
$aggregator = new ConfigAggregator([
    // ...
    Zend\Mvc\ConfigProvider::class,
    Zend\Router\ConfigProvider::class,

    // Default App module config
    Application\ConfigProvider::class,

    new PhpFileProvider(realpath(__DIR__) . '/autoload/{{,*.}global,{,*.}local}.php'),
    // ...
], $cacheConfig['config_cache_path']);

return $aggregator->getMergedConfig();
// public/index.php

// ...
chdir(dirname(__DIR__));
require 'vendor/autoload.php';
 
(function () {
    $container = require 'config/container.php';
    $app = $container->get(\Zend\Mvc\Application::class);
    $app->run();
})();

Controller plugins deprecation proposal

This RFC proposes to deprecate controller plugins and to remove plugin manager from abstract controllers.

Controller plugins (previously helpers) originate in pre-container times, when injecting dependencies was hard. It is no longer true.
Controller plugins provided via service locator obscure dependencies. Non-enforceable expectations of plugin behavior and interface create worst kind of implicit dependency entanglement. Mere presence of plugin manager in controllers erodes unit boundaries, making harder to unit test controllers.
By design, plugins provide side-effect based behavior. For example, createNotFoundViewModel sets 404 status in controller’s Response instance. Oops.

Zend-expressive is doing well without such concept and I believe it will be beneficial for ZF users as it will encourage them to switch to dependency injection if they didn’t already.

As a side effect, deprecation will allow us to repurpose existing plugins and plugin manager to provide backward compatibility layer for zend-http based v3 controllers.

Controllers as PSR-15 Request Handlers proposal

This RFC proposes to change zend-mvc controller to be a PSR-15 request handler. Requiring controller to always return Response, as per interface, will make it ultimate authority in handling request and preparing response and will grant it more control.

Implementation wise it will mean additional render event in AbstractController and its descendants. Existing event powered flexibility will not be lost: rendering listeners will also attach to shared event manager on identifier Zend\Mvc\Controller\ControllerInterface that is going to replace PSR-7 incompatible Zend\Stdlib\DispatchableInterface.
For AbstractController, dispatch event listeners will not be affected. As a consequence AbstractActionController actions will not be affected as well: they will still be able to return values, view models or Response object.

I believe, rendering happening within controller events will make zend-mvc event system easier to understand for users, greatly improving on learning curve.
Request handlers that are not zend-mvc controllers will be free to use any renderer, if any.

To improve user experience, fallback EventManager will not be created in controllers to force its injection and helpful errors:

public function handle(Request $request) : ResponseInterface
{
    $events = $this->getEventManager();
    if (! $events) {
        throw new RuntimeException('Controller %s requires EventManager with SharedEventManager provided by zend-mvc application to be composed');
    }
    // ...
}

Additionally, in case response is not provided after dispatch and render controller events, event manager will be inspected for shared manager and presence of zend-mvc listeners to provide helpful feedback.

Create new mvc skeleton application

Documentation for older versions of framework reference composer create-project zendframework/skeleton-application without version constraint and then tutorial proceeds to give instructions for version 2.4 while installed skeleton is actually for mvc v3. That creates a lot of confusion for new users. I propose to create new zendframework/zend-mvc-skeleton repository and package for v4 to avoid further confusion and to be more in line with the rest of the framework.


#2

As I understand events will not be lost?
Now we can attach listeners to the MVC’s events like EVENT_ROUTE, EVENT_DISPATCH etc.
How this will be retained in new version?

I’m looking forward to the new version and progress in architecture of the framework :smiley:


#3

Current event flow looks something like this:

  • Application -> EVENT_ROUTE
  • Application -> EVENT_DISPATCH
    • Dispatch listener -> $controller->dispatch()
      • AbstractController -> EVENT_DISPATCH
        • AbstractActionController::onDispatch() -> fooAction() : ViewModel
  • Application -> EVENT_RENDER
    • DefaultRenderingStrategy
  • Application -> EVENT_FINISH
    • ResponseSender listener

What I proposed there is slightly different:

  • Application -> EVENT_ROUTE
  • Application -> EVENT_DISPATCH
    • Dispatch listener -> $controller->dispatch()
      • AbstractController -> EVENT_DISPATCH
        • AbstractActionController::onDispatch() -> fooAction() : ViewModel
      • AbstractController -> EVENT_RENDER
        • DefaultRenderingStrategy, attached to SharedEventManager
  • Application -> EVENT_FINISH
    • ResponseSender listener

Render event moved to controller, rendering listeners are attached not to Application’s EventManager but to SharedEventManager with Controller’s identifier. Some other listeners are attached to SharedEventManager instead as well.


SharedEventManager usage is troubling though. Listeners are setup during mvc application bootstrap. That means that anything depending on it outside of mvc dispatch essentally is in undefined state. Which in turn means that controllers are not safe to reuse outside of mvc application and that defeats the purpose of making them psr request handlers.


#4

nice, that make sense.


#5

I like the reduction of complexity and also the new application setup. This reduces the differences between a mvc-based and a zend-expressive application. :+1:

When the controller plugins have been deprecated, we should perhaps add an alternative solution for each plugin in the documentation. (ask me for help)


One question from me: Are there any plans for the “V(iew)” part of mvc? Like the ViewManager.


#6

I do not intend to touch View part, for now at least. Except for possible change of where Render event takes place. I try to reign myself in to keep from changing everything. MUST CHANGE EVERYTHING.


#7

Plugin functionality itself will not be removed, only plugin manager, side effects and dependency on controller state.

For example Url plugin:

// factory
return new Controller($container->get(Zend\Mvc\Controller\Plugin\Url::class));

usage, assuming it assigned to property url:

// with plugin manager:
$url = $this->url()->fromRoute(...);
// turns into
$url = $this->url->fromRoute(...);

#8

Plugin functionality itself will not be removed, only plugin manager, side effects and dependency on controller state.

If I understand correct, then this will end in this type of constructor:

return new Controller(
    $container->get(MyRepository::class),
    $container->get(Form::class),
    $container->get(Zend\Mvc\Controller\Plugin\FlashMessenger::class),
    $container->get(Zend\Mvc\Controller\Plugin\Identity::class),
    $container->get(Zend\Mvc\Controller\Plugin\Params::class),
    $container->get(Zend\Mvc\Controller\Plugin\Redirect::class),
    $container->get(Zend\Mvc\Controller\Plugin\Url::class)
);

Then the end user would ask, why not simplify the constructor:

return new Controller(
    $container->get(MyRepository::class),
    $container->get(Form::class),
    $container->get(Zend\Mvc\Controller\Plugin\PluginManager::class)
);

But the question is, do we really need the constructor for that? Or can we use the same solution like in expressive:

$flashMessenger = $request->getAttribute('flash');

#9

I was not aware of such use of request attributes in expressive. Something to think about.

PluginManager and plugins are providing mixins for controllers that make assumptions about controller internals, some of them change controller state. They are hard and unreliable to mock, making unit testing much harder than it needs to be.

In general I believe this approach goes against design by contract ideology adopted by Zend Framework.
When this side-channel removed plugin manager becomes a simple service locator. There is no real reason for it to exist as a separate thing from main container anymore. In that case same arguments apply about injecting container as service locator versus injecting dependencies directly.

I should have been more specific about keeping the functionality: I want to replace plugins with controller independent, or mvc independent where possible, helpers.
Eg Url plugin could be replaced with Zend\Router\Helper\UrlHelper. Dependency injection actually makes more sense this way.

For the convenience side we already have reflection based LazyControllerAbstractFactory that can use typehints to fetch dependencies from container. And, i believe, we can provide tooling for generating controllers and factories, similar to expressive tooling.


#10

Sorry, maybe I was a little bit unclear. I do not want to keep the current implementation, my intention is a simple migration and a user friendly usage including the design by contract ideology.
If a user needs to revise all existing controllers, I see a problem and that’s why I’m asking.


#11

Users will need to revise controllers either way, because of Request/Response change.
What i meant by this

is that there is a possibility we can provide abstract controller that utilizes psr bridge to convert request/response and allows to keep legacy controllers mostly or completely unchanged. That will require existing plugins and plugin manager to work the way they are currently and this is actually where deprecation comes as helpful.
For example, in v4 we can keep PRG plugin returning expected zend-http response as in mvc v2 and v3. Internally it can be changed to use actual PRG for mvc v4.

Of course we can’t cover everything and there will be a lot of corner cases that will require effort in migration. Most important part will be documenting it in great detail.


#12

Is there a planned future for zend-http in any of this? Or is it just going to provide BC until we all switch to diactoros?

I for one would be in favour of going all in on PSR-15 and requiring some modification to existing controllers to support mvc v4. It’s still not going to be zf1->zf2 differences which is what I think most are afraid of with BC breaks.


#13

for zend-http there are no plans as far as i know, but I guess it will be migrated to PSR-7 at some point as a http client, may be headers container will remain.


#14

Then I agree with:

As for:

It’s not unsimilar to the required changes for service factories going from v2 to v3 of service manager. I expect to have to do some work to upgrade.


#15

It might not be but it will still require significant amount of effort in migration due to number of small changes.

For example listener that gets its response object from container will need to be rewritten to obtain it from mvc event. Some services that depend on response object will need to be changed to receive it at call time and return with changes. Code that used zend-http headers will need to find a way to deal with plain header strings in psr requests and so on.


#16

Ah I get your point now… Luckily I listened to you when you told me to keep my controllers thin…

What if there was a forward compatibility layer in mvc 3.x, similar to what we did with service manager 2.5+? I’m not entirely sure if that would be doable, I’m sure you can comment to that, but my thinking is that the abstract controller you spoke of would be that layer. Not to allow v3 controllers to work in v4, but to allow v3 controllers to work in v3 whilst developing them to move to v4?

I’m sure I’ve not explained myself as well as I could, but what I’m thinking is the same approach taken with servicemanager v3. If I can have the smooth optimised PSR-15 workflow in MVC-v4 without requiring an extra layer on top to make it compatible, that would be my preference.

EDIT: And I think is actually what you’re proposing, I just want to give some support to the idea that if a user moves to v4, they should be ready for v4.


#17

You mention that listeners would now be attached by the Application factory, which would look for listener aggregates in configuration, and attach them to the event manager.

I have a few questions:

  • How will it differentiate shared listeners from instance listeners?
  • What will the configuration look like?

It would be interesting to prototype this for a v3 release, and provide migration tools and/or documentation, so that developers can rewrite their onBootstrap() methods to be … whatever the new solution is?

I can see some other possibilities around this:

  • Updating the SharedEventManager factory to look for a mapping of context => event => listeners that it then injects into itself. This would solve attaching shared listeners.
  • Recommending delegator factories for attaching listeners to per-instance event managers. This is slightly more work, but it also provides for the dynamic attachment of listeners (i.e., only when the target class is pulled from the container), and it utilizes container features that are already well documented.

Either way, I’d like to see some examples, because I think this aspect of the module manager and modules is the part that will be the trickiest for migration.


#18

To limit the scope of changes I do not intend to introduce new way to attach listeners. A better way to attach listeners could be proposed as a separate change.

As for the currently proposed way, extra application listeners are moved from Application::init() to application factory. Also extra listeners parameter is moved from bootstrap($listeners) to constructor.

This is current Application::init():

One notable change here is that I would like to move config entry for listeners under Zend\Mvc\Application key to avoid possible collisions:

return [
    'dependencies' => [
        'factories' => [
            RbacListenerAggregate::class => InvocableFactory::class,
        ],
    ],
    Application::class => [ // one more level added
        'listeners' => [
            RbacListenerAggregate::class, // would lookup in container as it does in v3
            new RbacListenerAggregate(), // would use this instance
        ], 
    ],
];

Module::onBootstrap() could be easily replicated with addition of convenience abstract aggregate listener for onBootstrap event. Except it would need to be explicitly registered in container and added to config.

class RbacListenerAggregate extends OnBootstrapListenerAggregate
{
    public function onBootstrap(EventInterface $event)
    {
        $application = $event->getTarget();
        $container = $application->getContainer();
        $eventManager = $application->getEventManager();
        /* @var \ZfcRbac\Guard\GuardInterface[]|array $guards */
        $guards = $container->get('ZfcRbac\Guards');
        // Register listeners, if any
        foreach ($guards as $guard) {
            $guard->attach($eventManager);
        }
}

This is what it looks like in the actual module, pretty much the same:

ModuleManager for pre-container module loading would need to be updated to aggregate onBootstrap() from modules into listener aggregate instance and inject it into config. Later on Application factory will pick it up and attach to application.
Something like that:

namespace Zend\ModuleManager;

class MvcOnBootstrapModuleListenerAggregate extends AbstractListenerAggregate
{
    public function attach(EventManager $events)
    {
        foreach($this->modules as $module) {
            $events->attach(MvcEvent::EVENT_BOOTSTRAP, [$module, 'onBootstrap']);
        }
    }
}

#19

I get the dislike of controller plugins. For one, they screw up PHPStan and PHPStorm.

But, I can’t help thinking magic in PHP is one of the unique features of the language and needs to be leveraged, not avoided. In a way, it allows PHP to become a DSL. Do the readability, ease, and utility benefits come close to outweighing the hidden dependencies, testing problems, and other downsides?