Search code examples
phptwigsymfony4

Intervene template rendering


I have a controller method which I am using to "collect" variables to be assigned to template. I have overridden controller's render() method to merge "collected" and render parameters and assign them to template.

Example:

class Controller extends \Symfony\Bundle\FrameworkBundle\Controller\Controller
{
    private $jsVars = [];

    protected function addJsVar($name, $value)
    {
        $this->jsVars[$name] = $value;
    }

    public function render($view, array $parameters = [], Response $response = null)
    {
        return parent::render($view, array_merge($parameters, ['jsVars' => $this->jsVars], $response);
    }

    public function indexAction()
    {
        // collect variables for template
        $this->addJsVar('foo', 'bar');

        return $this->render('@App/index.html.twig', ['foo2' => 'bar2']);
    }
}

I just upgraded to Symfony 3.4 which complains that since Symfony4 I am not allowed to override render() method as it will be final.

How could I make it work seamlessly, i.e without defining a new method?

  • I know about Twig globals but these dont help me
  • I could use a service to collection variables and inject that service to Twig but that seems odd
  • Are there events I could listen, e.g TwigPreRender or smth?

Solution

  • Seems that there is no easy way.

    Basically there are 2 options:

    • create your own template engine by extending current Symfony\Bundle\TwigBundle\TwigEngine
    • decorate current templating engine service templating.engine.mytwig

    I chose the latter.

    Few explanations:

    • I created service templating.engine.mytwig which decorates current engine templating.engine.twig. Class will get current ´TwigEngine` as input and I'll delegate most of the stuff to it
    • The class also needs to be twig extension by implementing \Twig_ExtensionInterface (or extending \Twig_Extension was sufficient for me). Also service needs to have tag twig.extension. Otherwise you'll end up having errors such as "Cannot find private service 'assetic' etc"
    • setParameter/getParameter are for collecting and returning parameters
    • Then I added shortcut methods to my Controller - setJsVar
    • Twig template requires also handling of those variables, preferably somewhere in the layout level. But that is not included here
    • One could you this solution to collect arbitrary template parameters, e.g if you want to assign from another method or whatever
    • It would be good idea to clear collected parameters after render

    Was that all worth it? I dont know :) Cannot understand why Symfony team chose to make Controller::render final in the first place. But anyway here it is:

    TwigEnging class:

    namespace My\CommonBundle\Component\Templating\MyTwigEngine;
    
    use Symfony\Bundle\FrameworkBundle\Templating\EngineInterface;
    use Symfony\Bundle\TwigBundle\TwigEngine;
    use Symfony\Component\HttpFoundation\Response;
    
    class MyTwigEngine extends \Twig_Extension implements EngineInterface
    {
        /**
         * @var TwigEngine $twig Original Twig Engine object
         */
        private $twig;
        /**
         * @var array $parameters Collected parameters to be passed to template
         */
        private $parameters = [];
    
    
        /**
         * MyTwigEngine constructor.
         *
         * @param TwigEngine $twig
         */
        public function __construct(TwigEngine $twig)
        {
            $this->twig = $twig;
        }
    
        /**
         * "Collects" parameter to be passed to template.
         *
         * @param string $key
         * @param mixed $value
         *
         * @return static
         */
        public function setParameter($key, $value)
        {
            $this->parameters[$key] = $value;
            return $this;
        }
    
        /**
         * Returns "collected" parameter
         *
         * @param string $key
         * @return mixed
         */
        public function getParameter($key, $default = null)
        {
            $val = $this->parameters[$key] ?? $default;
    
            return $val;
        }
    
        /**
         * @param string|\Symfony\Component\Templating\TemplateReferenceInterface $name
         * @param array $parameters
         *
         * @return string
         * @throws \Twig\Error\Error
         */
        public function render($name, array $parameters = array())
        {
            return $this->twig->render($name, $this->getTemplateParameters($parameters));
        }
    
        /**
         * @param string $view
         * @param array $parameters
         * @param Response|null $response
         *
         * @return Response
         * @throws \Twig\Error\Error
         */
        public function renderResponse($view, array $parameters = array(), Response $response = null)
        {
            return $this->twig->renderResponse($view, $this->getTemplateParameters($parameters), $response);
        }
    
        /**
         * @param string|\Symfony\Component\Templating\TemplateReferenceInterface $name
         *
         * @return bool
         */
        public function exists($name)
        {
            return $this->twig->exists($name);
        }
    
        /**
         * @param string|\Symfony\Component\Templating\TemplateReferenceInterface $name
         *
         * @return bool
         */
        public function supports($name)
        {
            return $this->twig->supports($name);
        }
    
        /**
         * @param $name
         * @param array $parameters
         *
         * @throws \Twig\Error\Error
         */
        public function stream($name, array $parameters = array())
        {
            $this->twig->stream($name, $this->getTemplateParameters($parameters));
        }
    
    
        /**
         * Returns template parameters, with merged jsVars, if there are any
         * @param array $parameters
         * @return array
         */
        protected function getTemplateParameters(array $parameters = [])
        {
            $parameters = array_merge($this->parameters, $parameters);
    
            return $parameters;
        }
    }
    

    Decorator service (services.yml):

    services:
        templating.engine.mytwig:
            decorates: templating.engine.twig
            class: My\CommonBundle\Component\Templating\MyTwigEngine
            # pass the old service as an argument
            arguments: [ '@templating.engine.mytwig.inner' ]
            # private, because you probably won't be needing to access "mytwig" directly
            public:    false
            tags:
                - { name: twig.extension }
    

    Base controller alteration:

    namespace My\CommonBundle\Controller;
    
    use My\CommonBundle\Component\Templating\MyTwigEngine;
    
    
    abstract class Controller extends \Symfony\Bundle\FrameworkBundle\Controller\Controller
    {
        /**
        * Allows to set javascript variable from action
        *
        * It also allows to pass arrays and objects - these are later json encoded
        *
        * @param string $name Variable name
        * @param mixed $value - string|int|object|array
        *
        * @return static
        */
        protected function setJsVar($name, $value)
        {
            /** @var MyTwigEngine $templating */
            $templating = $this->getTemplating();
            if (!$templating instanceof MyTwigEngine) {
                throw new \RuntimeException(sprintf(
                    'Method %s is implemented only by %s', __METHOD__, MyTwigEngine::class
                ));
            }
    
            $jsvars = $templating->getParameter('jsVars', []);
            $jsvars[$name] = $value;
            $templating->setParameter('jsVars', $jsvars);
    
            return $this;
        }
    
        /**
         * Returns templating service
         * @return null|object|\Twig\Environment
         */
        private function getTemplating()
        {
            if ($this->container->has('templating')) {
                $templating = $this->container->get('templating');
            } elseif ($this->container->has('twig')) {
                $templating = $this->container->get('twig');
            } else {
                $templating = null;
            }
    
            return $templating;
        }
    }