Middleware with Controller Action

@voltan, found your topic The process of using the Middleware - #3 by xerkus
did you find applicable way?

@ocramius,
i have legacy app on ZF2, which i upgraded to ZF3.

simple flow for middleware for 1 route:

1. request for reset-password
2. middleware, which attaches business-logic (synchronize passwords, logging and etc)
3. vendor-code in controller's action processes reset-password's logic

so middleware looks the best place for this 1 route, which will be used rarely. so we don’t have any overhead with logic for others 100 routes.

i used it in ZF2 as code to run before controller’s action and as for me it is the most useful thing.
now after upgrade i see that middleware doesn’t work in the same way… i spent 1 day to read different information and still don’t see some strict deprecation notice in the documentation…

very strange situation.
i have big controllers and don’t want to break each action into separate RequestHandler.

questions:

  1. am i correctly understand that it was killed flow, when middleware could work before controller’s action? WHY?
  2. if YES - what is suggested ways to replace such logic in the new way?

thanks!

What does this code look like?

It works as before: a middleware cannot run in front of a controller.
But an event listener can be used in combination with a controller:

Or do you mean the old option to using a middleware within event listeners?

What you had was double pass interop middleware, an implementation for work-in-progress middleware specification. It was not the middleware that ended up as final PSR in 2018.

Interop middleware passed both request and response objects in and returned null or response. That allowed middleware in from of controller to be visited as listeners. PSR middleware always returns a response and is not compatible with the approach you want.

If you would look at the way middleware usage in listeners used to be suggested:

 $eventManager->attach($e::EVENT_DISPATCH, function ($e) use ($services) {
            $request  = Psr7ServerRequest::fromLaminas($e->getRequest());
            $response = Psr7Response::fromLaminas($e->getResponse());
            $done     = function ($request, $response) {
            };

            $result   = ($services->get(Middleware\AuthorizationMiddleware::class))(
                $request,
                $response,
                $done
            );

            if ($result) {
                return Psr7Response::toLaminas($result);
            }
        }, 2);

This is simply impossible with PSR middleware:

public function process(
    ServerRequestInterface $request,
    RequestHandlerInterface $handler
): ResponseInterface;

You can have a hack listener with final request handler in the listener middleware pipe returning fake response object to differentiate between middleware acting on request and pass-through behavior but you would not be using Middleware contract with that approach.
I DO NOT RECOMMEND trying to do that. It is brittle and represents a maintenance burden. Better spend your effort migrating controllers to middleware.

1 Like

The problem is really down to incompatibility between modes of operation. Middleware expects to go down call stack, acting on way in and on result on way out. MVC event based system does not go down the call stack, instead each listener is expected to return before going to the next listener.

Best you can do here is extract middleware logic into service object and reuse that between middleware and listener. They can’t be the same. Considering that laminas-http and PSR-7 are not compatible, I would say sharing basic logic between the two is generally not desirable while business logic implementation should be injected anyway.

1 Like

a middleware cannot run in front of a controller.

it worked before controller action in ZF2. i debugged it 2 minutes ago (in ZF2 and ZF3).

Config:

'zfcuser' => array(
	            'child_routes' => array(
		            'resetpassword' => array(
			            'type' => 'Segment',
			            'options' => array(
				            'route' => '/reset-password/:userId/:token',
				            'defaults' => array(
					            'middleware' => \User\Goalio\ForgotPassword\Middleware\ResetPassword::class,
				            	'controller' => 'goalioforgotpassword_forgot',
					            'action'     => 'reset',
				            ),
				            'constraints' => array(
					            'userId'  => '[A-Fa-f0-9]+',
					            'token' => '[A-F0-9]+',
				            ),
			            ),
		            ),
	            ),
            ),

Middleware:

namespace User\Goalio\ForgotPassword\Middleware;

use User\Goalio\ForgotPassword\Listener\ResetPasswordListener;

class ResetPassword {
	public function __construct( $eventManager ) {
		$eventManager->attach( new ResetPasswordListener() );
	}
}

ResetPasswordListener:

<?php

namespace User\Goalio\ForgotPassword\Listener;

use Album\H\Token;
use Album\Model\Transaction;
use User\H\Curl;
use User\Model\UsrTb;
use Zend\EventManager\EventManagerInterface;
use Zend\EventManager\ListenerAggregateInterface;

class ResetPasswordListener implements ListenerAggregateInterface
{
    /**
     * @var \Zend\Stdlib\CallbackHandler[]
     */
    protected $listeners = [];
    protected $serviceManager;

    /**
     * {@inheritDoc}
     */
    public function __construct(\Zend\ServiceManager\ServiceLocatorInterface $serviceLocator = null)
    {
        $this->serviceManager = $serviceLocator;
    }

    public function attach(EventManagerInterface $events)
    {
        $sharedEvents = $events->getSharedManager();
        $this->listeners[] = $sharedEvents->attach(
        	'User\Goalio\ForgotPassword\Service\Password',
            ['resetPassword'],
            [$this, 'action'],
            100);
	    $this->listeners[] = $sharedEvents->attach(
		    'User\Goalio\ForgotPassword\Service\Password',
		    ['resetPassword.post'],
		    [$this, 'actionPost'],
		    100);
    }

    public function detach(EventManagerInterface $events)
    {
        foreach ($this->listeners as $index => $listener) {
            if ($events->detach($listener)) {
                unset($this->listeners[$index]);
            }
        }
    }

    public function action($e)
    {
	    $user     = $e->getParam( 'user');
        $password = $e->getParam('password');

        $user->syncFTPUsrPwd($password);

	    Transaction::begin();

        return $this;
    }

	public function actionPost($e)
	{
		Transaction::commit();

		return $this;
	}

}

@xerkus
so this above worked way was temporary solution and now it was replaced with different approach, when middleware can return response WITHOUT further run action of controller?

as fast solution i changed my middleware to code in Module.php:

	public function onBootstrap( Event $e )
	{
		$this->attachListeners( $e );
	}

	public function attachListeners( Event $e )
	{
		$eventManager = $e->getApplication()->getEventManager();

		$sm               = $e->getApplication()
							  ->getServiceManager();

		$eventManager->attach( MvcEvent::EVENT_ROUTE, function ( $e ) use ( $eventManager ) {
			$routeMatch = $e->getRouteMatch();

			# this part was done as middleware ResetPassword::class, BUT ZF3 killed they way, how it worked in ZF2, so moved here
			if ( in_array( $routeMatch->getMatchedRouteName(), [
				Route::ZFCUSER_RESET_PASSWORD,
			] ) ) {
				( new ResetPasswordListener() )->attach( $eventManager );
			}
		} );
	}

but this code will run for every request, what will have additional uneeded overhead for other routes.
abstractly saying in real world not every controller action can’t be migrated to separate RequestHandler, because it can be vendor-code (in my case it is).

maybe i am missing something.

@froschdesign
@xerkus
how to resolve this task including each required target:

  1. code runs only for concrete related 1 (1…N) routes
  2. controller action is fixed (vendor-code), but we need to run it after

it seems ideal solution (which resolves both targets) is absent?

thanks!

What you describe here is not middleware at all. It does not act on request or response.

ResetPasswordListener already listens to specific event. It does not need middleware or listener to conditionally register it. Register it directly on bootstrap. It won’t be called unless resetPassword event is called on password service.

I also think that there is a confusion here or that unnecessary intermediate steps are being taken and the whole middleware approach is not needed. At least I don’t see it.

if you don’t see in this code ‘middleware’ - it doesn’t mean that next flow is unneeded:

request1 -> [middleware1, middleware2] -> controller action1
request2 -> [middleware1, middlewareN] -> controller action2
request3 -> [middleware1, middlewareZ] -> controller action3

we can put in onBootstrap everything in the world, but this is not real solution, because make start of module heavier and longer.

for example Laravel-logic… it works with middleware in the way i described above.
routes grouped by sense, each group has own middlewares needed in context of business-logic.
they don’t register all project events for every request… separation of concerns works.

isn’t it?

Registering event listeners in onBootstrap is lightweight. Providing listener to conditionally register anoter listener will not help you.
If listener needs heavy dependencies you can register lazy listener that will only create real listener with dependencies when invoked.

While middleware are convenient, I use and love Mezzio, they are not available for controllers and middleware is not a suitable place for attaching listeners to be used elsewhere else. You will not get satisfying result trying to force it here.

Alternative to attaching on bootstrap could be to attach on service creation via delegators when you know specific service that needs listeners:

Delegator would look something like this:

use Psr\Container\ContainerInterface;
use User\Goalio\ForgotPassword\Listener\ResetPasswordListener;
use User\Goalio\ForgotPassword\Service\Password;

static function __invoke(ContainerInterface $container, string $name, callable $callback): Password
{
    $service = $callback();
    assert($service instanceof Password);
    $aggregateListener = $container->get(ResetPasswordListener::class);
    $aggregateListener->attach($service->getEventManager());
    
    return $service;
}

Assuming you attach to actual event manager and not shared as in your sample aggregate listener code.
I assume Password service implements EventsAware/EventsCapable interfaces and it’s factory actually injects event manager. If event manager is injected by initializer that happens too late. Event manager from delegator with already attached listeners will be overridden by initializer.

I am curious, your example here of specifying both middleware and controllers in the route definition. Did that actually work? Was that how you used it?

It was certainly not intended to be used that way. It was never tested or documented. I can see how not returning a response could fall back to controller. Damn, I was not expecting zend-mvc to surprise me so.

example of my usage of eventListener - it is only example. i spoke about general metodology. i can give another examples, but they also will not work in ZF3, how it worked in ZF2.

so we are fixing requirement that we have controllers (vendor or own) with actions.
for example such middlewares:

0. Maintenance - check maintenance status
1. Logging - log request
2. Rate Limit - check some limits to ban bad guys
3. Authentication - check token
4. Authorization - check acl
5. ....

and after all we should run controller and action.

in some routes we will need saying only Logging and that’s all.
in some routes we don’t need Authentication and Authorization.
and so on.

it is very useful was to place list of such middlewares in config (ZF2).
in our case (ZF3 and absence refactoring of controller action to RequestHandler) we need as workaround to place all this logic around different service factories, onBootstrap, eventListeners, …, which will be run ONLY in case request1.

if we imagine 100 different requests - such implementation fastly will look like a mess.

yes, it works as it should)
i was happy, when it was released and i thought about adding a lot other middlewares also after migrate to ZF3

it works in the next way:

  1. route has middleware-config
  2. zend before run DispatchListener has added MiddlewareListener
  3. MiddlewareListener initiates from config middleware-instance
  4. MiddlewareListener runs instance
  5. MiddlewareListener checks result
  6. if it is empty - it continues DISPATCH-event, which calls controller action

MiddlewareListener code :
or here: https://github.com/zendframework/zend-mvc/blob/a8d45689d37a9e4ff4b75ea0b7478fa3d4f9c089/src/MiddlewareListener.php#L37-L91

...
    public function onDispatch(MvcEvent $event)
    {
        $routeMatch = $event->getRouteMatch();
        $middleware = $routeMatch->getParam('middleware', false);
        if (false === $middleware) {
            return;
        }

        $request        = $event->getRequest();
        $application    = $event->getApplication();
        $response       = $application->getResponse();
        $serviceManager = $application->getServiceManager();
        $middlewareName = is_string($middleware) ? $middleware : get_class($middleware);

        if (is_string($middleware) && $serviceManager->has($middleware)) {
            $middleware = $serviceManager->get($middleware);
        }
        if (! is_callable($middleware)) {
            $return = $this->marshalMiddlewareNotCallable($application::ERROR_MIDDLEWARE_CANNOT_DISPATCH, $middlewareName, $event, $application);
            $event->setResult($return);
            return $return;
        }

        $caughtException = null;
        try {
            $return = $middleware(Psr7Request::fromZend($request), Psr7Response::fromZend($response));
        } catch (\Throwable $ex) {
            $caughtException = $ex;
        } catch (\Exception $ex) {  // @TODO clean up once PHP 7 requirement is enforced
            $caughtException = $ex;
        }

        if ($caughtException !== null) {
            $event->setName(MvcEvent::EVENT_DISPATCH_ERROR);
            $event->setError($application::ERROR_EXCEPTION);
            $event->setController($middlewareName);
            $event->setControllerClass(get_class($middleware));
            $event->setParam('exception', $caughtException);

            $events  = $application->getEventManager();
            $results = $events->triggerEvent($event);
            $return  = $results->last();
            if (! $return) {
                $return = $event->getResult();
            }
        }

        if (! $return instanceof PsrResponseInterface) {
            $event->setResult($return);
            return $return;
        }
        $response = Psr7Response::toZend($return);
        $event->setResult($response);
        return $response;
    }
...

as for me it is most simple usage as it needs to be.

in ZF3 MiddlewareListener was rewritten, it became harder to understand and my debug didn’t get the same result

Ok. I see now. You are really upgrading to Zend Framework 3. Last release of zend-mvc (3.1.1) was 6 years ago. Zend Framework migrated to Laminas Project in 2020.

Since laminas-mvc 3.2.0 interop middleware no longer supported. Deprecated code was left in place but it is unusable except on very old php versions.laminas/laminas-mvc-middleware replaced middleware functionality with upgrade to PSR-15 which itself was finalized in 2018.

PSR-15 middleware can not be used in front of controllers since middleware MUST return response. It’s signature is in my first answer.

You happened to rely on unintended behavior that was fixed. Sorry about that but that path is closed.

Generally speaking I would recommend to register listeners on bootstrap anyway and from config provide list of route names for them to act upon. When you need specific controllers then register listeners for specific identifiers. For controllers extending from AbstractController those are top most namespace, full namespace and fully qualified class name.


I am not endorsing this, but if you still want to provide listeners from route definitions you can do this:

  • Introduce new unique parameter in route definitions and provide value object container to hold parameter value. Do not use scalar values as they CAN come from user request, unlike objects.
    Look at PipeSpec laminas-mvc-middleware/PipeSpec.php at 2.3.0 · laminas/laminas-mvc-middleware · GitHub You need __set_state() magic method for config caching.
  • From onBootstrap() register listener on routing event with priority to be AFTER routing.
    Pretty much what you did in your sample code, except do it with provided priority parameter.
  • In the listener check route match object for presence of your new parameter AND for the parameter to be instance of your value object. Do nothing if either not true.
  • Grab list of aggregate listeners from value object, get them from container and attach them.

Remember that children in tree router inherit parameters from parents unless different value provided explicitly.


For the long term please consider migration to Mezzio mezzio - Laminas Docs
Middleware is superior to mvc and it comes with significantly improved interoperability thanks to PSR-15.

1 Like

@xerkus

i understood you answers.
big thanks for the fast valuable reaction!