Skip to content

Navigation Menu

Sign in
Appearance settings
Sign up
Appearance settings
Discussion options

Hello, I wanted to ask about something - I was looking around, seeing if I can easily implement a transaction mechanism on our controllers.

Turns out - it's not as easy, mostly because the required logic is not quite there.

The HttpKernel resolves arguments, dispatches an event, and then calls the controller. As such, it's not really possible to override this natively.

I was wondering, if a feature that'd add a callback with an event dispatching would be accepted.
Basically this:

Instead of the kernel running the requests like so:

        // controller arguments
        $arguments = $this->argumentResolver->getArguments($request, $controller, $event->getControllerReflector());

        $event = new ControllerArgumentsEvent($this, $event, $arguments, $request, $type);
        $this->dispatcher->dispatch($event, KernelEvents::CONTROLLER_ARGUMENTS);
        $controller = $event->getController();
        $arguments = $event->getArguments();

        // call controller
        $response = $controller(...$arguments);

It'd be

$event = new ControllerDispatchEvent(function (): mixed {
        // controller arguments
        $arguments = $this->argumentResolver->getArguments($request, $controller, $event->getControllerReflector());

        $event = new ControllerArgumentsEvent($this, $event, $arguments, $request, $type);
        $this->dispatcher->dispatch($event, KernelEvents::CONTROLLER_ARGUMENTS);
        $controller = $event->getController();
        $arguments = $event->getArguments();

        // call controller
        $response = $controller(...$arguments);
});
$this->dispatcher->dispatch($event);
$response = $event->getResult();

// continue as regular

This would allow subscribers to listen for a "full" dispatch event, making it possible to wrap the callback in extra logic, in our case, a retryable transaction.

This can kinda be achieved already, but it's not a clean way, as you'd absolutely need to dump the first results of resolvers completely, in case they're not yet in a transaction, and override the returned controller (which is fine), but I don't really like the extra resolver call, as it could mess with some logic.

edit: alternate possible implementation without callbacks

$maxAttempts = 3; // preferably configurable from symfony's config
$attempt = 0;

while (true) {
        $event = new ControllerDispatchStartEvent();
        $this->dispatcher->dispatch($event);

        // possible add exit point via $event->getResponse?

        // continue as normal
        // controller arguments
        $arguments = $this->argumentResolver->getArguments($request, $controller, $event->getControllerReflector());

        $event = new ControllerArgumentsEvent($this, $event, $arguments, $request, $type);
        $this->dispatcher->dispatch($event, KernelEvents::CONTROLLER_ARGUMENTS);
        $controller = $event->getController();
        $arguments = $event->getArguments();

        // call controller
        try {
            $response = $controller(...$arguments);
        } catch (\Throwable $e) {
            $event = new ControllerDispatchExceptionEvent($e);
            $this->dispatcher->dispatch($event);

            if ($attempt >= $maxAttempts) {
               throw $e;
            }

            if ($event->shouldRetry()) {
               $attempt++;

               $event = new ControllerRetryEvent();
               $this->dispatcher->dispatch($event);
               continue;
            }
        }

        $event = new ControllerDispatchFinishEvent($response);
        $this-dispatcher->dispatch($event);

        if ($event->shouldRetry()) {
               $attempt++;

               $event = new ControllerRetryEvent();
               $this->dispatcher->dispatch($event);
               continue;
        }

        $response = $event->getResponse(); // would just default back to itself
        break;
}
You must be logged in to vote

Replies: 2 comments · 26 replies

Comment options

The use case makes sense, especially for cross-cutting concerns like transactions, retries, logging, or tracing around the complete controller execution.

However, adding a new event around the whole controller dispatch flow might not be the best abstraction.

Symfony already has several extension points in the controller lifecycle:

  • KernelEvents::CONTROLLER
  • KernelEvents::CONTROLLER_ARGUMENTS
  • KernelEvents::VIEW
  • KernelEvents::RESPONSE
  • KernelEvents::EXCEPTION

The missing part is a hook that wraps the actual controller invocation after arguments are resolved.

A possible alternative would be adding a dedicated event around controller execution, for example:

KernelEvents::CONTROLLER_DISPATCH

where listeners could wrap the execution:

public function onControllerDispatch(ControllerDispatchEvent $event): void
{
    $event->setResult(
        $this->transactionManager->transaction(
            fn () => $event->callController()
        )
    );
}

This would avoid re-running argument resolution and would keep the existing lifecycle intact.

For transactions specifically, another possible approach is using an attribute on controllers/actions and handling it through an event subscriber, but currently there is no clean event that wraps the complete controller call.

The main concern with adding a callback-based event is maintaining the current execution flow and avoiding hidden behavior changes. The event should probably expose the controller execution rather than expose an internal callback.

Something like:

$response = $event->execute();

or:

$event->wrap(callable $wrapper);

would make the intention clearer.

So the feature request seems useful, but the API design should probably focus on providing a stable "around controller execution" extension point rather than exposing the internal dispatch callback.

Ref.:

You must be logged in to vote
10 replies
@pkly
Comment options

The important part is keeping the default behavior unchanged and making any caching opt-in or explicitly supported by resolvers, because automatically caching arbitrary resolver output could introduce subtle behavior changes.

That could probably be a toggle or something, or just have the default flow through this event and generally be non-repeatable by default.
Since this would be a new event of sorts, you wouldn't have any consumers for it (other than the symfony one, which would trigger the callback), so it would be safe to add and would not change any behavior in existing applications.

Making the listener call a callable that triggers the whole controller handling logic is a no-go to me: this would call the controller multiple times when you add multiple listeners on that even that work this way. This is clearly not a proper API for an event-based extension point.

I'm open to suggestions as for how you'd solve it, but honestly I don't really see anything that would allow us to repeat an action other than making it simply a callback.

edit: actually, it would be possible, but it'd be still rather... weird?

$maxAttempts = 3; // preferably configurable from symfony's config
$attempt = 0;

while (true) {
        $event = new ControllerDispatchStartEvent();
        $this->dispatcher->dispatch($event);

        // possible add exit point via $event->getResponse?

        // continue as normal
        // controller arguments
        $arguments = $this->argumentResolver->getArguments($request, $controller, $event->getControllerReflector());

        $event = new ControllerArgumentsEvent($this, $event, $arguments, $request, $type);
        $this->dispatcher->dispatch($event, KernelEvents::CONTROLLER_ARGUMENTS);
        $controller = $event->getController();
        $arguments = $event->getArguments();

        // call controller
        try {
            $response = $controller(...$arguments);
        } catch (\Throwable $e) {
            $event = new ControllerDispatchExceptionEvent($e);
            $this->dispatcher->dispatch($event);

            if ($attempt >= $maxAttempts) {
               throw $e;
            }

            if ($event->shouldRetry()) {
               $attempt++;

               $event = new ControllerRetryEvent();
               $this->dispatcher->dispatch($event);
               continue;
            }
        }

        $event = new ControllerDispatchFinishEvent($response);
        $this-dispatcher->dispatch($event);

        if ($event->shouldRetry()) {
               $attempt++;

               $event = new ControllerRetryEvent();
               $this->dispatcher->dispatch($event);
               continue;
        }

        $response = $event->getResponse(); // would just default back to itself
        break;
}

something like this, maybe?

@nirav-gajera
Comment options

I think this approach actually avoids the biggest issue with the callback idea.

The good part is that HttpKernel still owns the execution flow. Listeners are only deciding whether something should be retried, instead of manually calling the controller execution themselves.

It also solves the main problem with transactions: on retry, the whole flow can run again:

  • resolve controller arguments
  • run CONTROLLER_ARGUMENTS
  • execute the controller

So things like #[MapEntity] or custom argument resolvers would get a fresh resolution instead of reusing stale objects from the previous attempt.

The main thing to be careful about is resetting state between attempts. A retry should not reuse anything from the previous run that may have changed, such as resolved arguments or request-scoped state.

I agree that a callback is the simplest way to represent a repeatable action, but this event-driven approach feels more Symfony-like because the kernel keeps control of when the controller is executed.

The main question would be whether retries should be a responsibility of HttpKernel itself or a separate layer built on top of this lifecycle.

@pkly
Comment options

Yeah, but this would not be aware of possible caches from argument resolvers, which again, could be a separate feature (with an optional interface which would be later required).

The only thing here is that the request should be copied from before it's mutated the first time, so we could keep re-using the fresh one (at least for the time being).

I believe this solution above would be sufficient to achieve the results we're aiming for, but again, this is just a proposal, to see if something like this would even be accepted. I believe there is a need to be able to transparently retry the controller request, currently we just do simple resolution in the arguments (usually just resolving to doctrine references and then re-loading the entities inside of a transaction with locks).

@nirav-gajera
Comment options

agree, the request state and resolver caching are probably the two areas that need the most attention.

keeping a copy of the original request before any mutation happens sounds like a practical first step. That would at least ensure that every retry starts from the same input state instead of carrying changes from the previous attempt.

the resolver cache problem feels like a separate concern though. A retry mechanism probably should not need to know about every possible resolver implementation. If argument resolvers need caching support in the future, an explicit contract/interface would make more sense so symfony can decide what can be reused safely

the important part of this proposal is that it creates a proper retry boundary around the whole controller lifecycle. Currently, applications have to work around this by resolving arguments first and then manually reloading things inside transactions, which is easy to get wrong.

Having an opt-in way to retry the complete flow transparently would make cases like Doctrine entity loading with locks much cleaner.

@pkly
Comment options

The cachable argument resolving could just be done strictly in the ArgumentResolver, so nothing in the kernel would need to know about it - the only issue is that there would need to be extra cache for a short while (could probably be cleared after the response is ready to be served).

As for the opt-in retries - I don't believe this would be symfony's responsibility? We have a bundle which provides a retryable service (wrapping around a callback, which is why my first idea was that), and we'd probably just hook into these events to handle it.

Maybe also add some sort of route marker, that we wish to actually start a transaction (not always needed) (#[WithTransaction] on the method in controller), but again, this would be outside of symfony's scope.

Comment options

You can change the controller after its arguments have been resolved by listening to kernel.controller_arguments?

You must be logged in to vote
16 replies
@pkly
Comment options

What good would forwarding do here? I'm not sure exactly, what you'd just forward the same request again?

So, you start a transaction on kernel.controller and then just run the controller inside of the transaction through decorating it with kernel.controller_arguments, but where's the possibility of a restart? All you added was a transaction, which you can just be done by starting it on the controller event and ending it on a response event.

But this is not what I'm after at all.

@MatTheCat
Comment options

Yes forwarding would allow to “replay” the request inside of the same process. Not sure this would work but the idea feels like a starting point.

where's the possibility of a restart? All you added was a transaction

You can also see the original controller call being wrapped in a try/catch. That would be your way of knowing something wrong happened.

@pkly
Comment options

Yeah but knowing something wrong happened is not useful. Symfony already sends an exception event if an exception is thrown in the controller.

@MatTheCat
Comment options

Since everything I say is wrong or not useful I’ll let you figure what you need by yourself 👋

@pkly
Comment options

I'm not trying to be rude or anything, I just don't think those solutions would help in achieving the effect I'm hoping for.

  • Restarting the transaction without reloading the arguments may hold stale data/entities (crash)
  • Knowing that an exception occurred inside of a controller is not very useful, if you're trying to be able to "restart" it, as that knowledge alone is not enough since you don't control the controller entrypoint (see above, stale arguments)
  • Symfony already produces an exception event if an exception occurs inside of the controller
  • Restarting transactions inside of a controller is already possible, but that assumes the transaction (and data) is loaded inside of the controller function (extra boilerplate and repeating code, we already do this)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Category
🙏
Q&A
Labels
None yet
4 participants
Morty Proxy This is a proxified and sanitized view of the page, visit original site.