Subdomain based mobile versions in zf2 projects

Martin Zeller php, zf2 0 Comments

A few weeks ago it was my job to extend an existing Zend Framework 2 project with a mobile version. The orderer wanted when calling mobile.example.com/path1 a for mobile clients optimized version of www.example.com/path1 should be delivered. Some mobile versions of pages should have only a changed layout, while others should provide additional content.

These requirements (for requests with the „mobile“ subdomain) I wanted to implement like this:

  1. an additional layout.phtml for the mobile layout
  2. check if there is a view phtml in a „mobile“ folder => if yes: use this view script (with the same controller) – if no: use the default view script
  3. check if there is a controller in a „mobile“ folder => if yes: use this controller – if no: use the default controller

An example folder structure for the „Application“ module:

Application
|- src
|  |-Application
|  |  |- Controller
|  |  |  |- Mobile
|  |  |  |  |- IndexController.php
|  |  |  |- IndexController.php
|  |  |  |- TestController.php
|- view
|  |- application
|  |  |- index
|  |  |  |- index.phtml
|  |  |  |- indexaction2.phtml
|  |  |- test
|  |  |  |- test.phtml
|  |- layout
|  |  |- layout.phtml
|  |- mobile
|  |  |- application
|  |  |  |- index
|  |  |  |  |- indexaction2.phtml
|  |  |  |- test
|  |  |  |  |- test.phtml
|  |  |- layout
|  |  |  |- layout.phtml

For our example let’s assume this route definition: /app/:controller/:action

Now let’s have a look to some requests:

  • http://www.example.com/app/index/index => would use Application\Controller\IndexController.php and application\index\index.phtml
  • http://mobile.example.com/app/index/index => would use Application\Controller\Mobile\IndexController.php and fall back to application\index\index.phtml (because we don’t have a mobile\application\index\index.phtml)
  • http://www.example.com/app/test/test => would use Application\Controller\TestController.php and application\test\test.phtml
  • http://mobile.example.com/app/test/test => would fall back to Application\Controller\TestController.php (because we don’t have a Application\Controller\Mobile\TestController.php) and use mobile\application\test\test.phtml
  • http://mobile.example.com/app/index/indexaction2 => would use both mobile versions: Application\Controller\Mobile\IndexController.php and mobile\application\index\indexaction2.phtml
  • http://mobile.example.com/* => all requests via „mobile“ subdomain would use the layout of mobile/layout/layout.phtml

First, we take care of the handling of the layout. For this, we define another default layout plugin called „SubDomainBasedLayoutControllerPlugin“. This class we let inherit from the ZF2 standard layout plugin and override the method „setTemplate“:

namespace Application\Controller\Plugin;

use Zend\Mvc\Controller\Plugin\Layout;
use Zend\ServiceManager\ServiceLocatorAwareInterface;
use Zend\ServiceManager\ServiceLocatorAwareTrait;

class SubDomainBasedLayoutControllerPlugin extends Layout implements ServiceLocatorAwareInterface {

    use ServiceLocatorAwareTrait;

    /**
     * Set the layout template
     *
     * @param  string $template
     * @return Layout
     */
    public function setTemplate($template)
    {
        $viewModel = $this->getViewModel();

        /** @var \Zend\Uri\Http $uri */
        $uri = $this->getServiceLocator()->getServiceLocator()->get('Request')->getUri();
        $host = $uri->getHost();

        $prts = explode('.', $host);
        $subDomain = array_shift($prts);

        if('mobile'==$subDomain)
            $template = 'mobile/' . $template;

        $viewModel->setTemplate((string) $template);
        return $this;
    }

}

The definition for our module.config.php:

'controller_plugins' => array(
        'invokables' => array(
            'layout' => 'Application\Controller\Plugin\SubDomainBasedLayoutControllerPlugin',
        )
    ),

For the handling of the optional mobile controllers we override the ZF2 standard ControllerLoaderFactory and the ZF2 standard ControllerManager:

namespace Application\Service\Factory;

use Application\Controller\Manager\SubDomainBasedControllerManager;
use Zend\Mvc\Service\ControllerLoaderFactory;
use Zend\ServiceManager\ServiceLocatorInterface;

class SubDomainBasedControllerLoaderFactory extends ControllerLoaderFactory {

    public function createService(ServiceLocatorInterface $serviceLocator)
    {
        $controllerLoader = new SubDomainBasedControllerManager();
        $controllerLoader->setServiceLocator($serviceLocator);
        $controllerLoader->addPeeringServiceManager($serviceLocator);

        $config = $serviceLocator->get('Config');

        if (isset($config['di']) && isset($config['di']['allowed_controllers']) && $serviceLocator->has('Di')) {
            $controllerLoader->addAbstractFactory($serviceLocator->get('DiStrictAbstractServiceFactory'));
        }

        return $controllerLoader;
    }

}

Our SubDomainBasedControllerLoaderFactory uses our SubDomainBasedControllerManager. The interesting part here is that our ControllerManager searches for a controller with the suffix „_Mobile“:

namespace Application\Controller\Manager;

use Zend\Mvc\Controller\ControllerManager;
use Zend\Console\Request as ConsoleRequest;

class SubDomainBasedControllerManager extends ControllerManager {

    /**
     * Override: do not use peering service managers
     *
     * @param  string $name
     * @param  array $options
     * @param  bool $usePeeringServiceManagers
     * @return mixed
     */
    public function get($name, $options = array(), $usePeeringServiceManagers = false)
    {
        /** @var \Zend\Stdlib\RequestInterface $request */
        $request = $this->getServiceLocator()->get('Request');

        if ($request instanceof ConsoleRequest)
            return parent::get($name, $options, $usePeeringServiceManagers);

        /** @var \Zend\Uri\Http $uri */
        $uri = $this->getServiceLocator()->get('Request')->getUri();
        $host = $uri->getHost();

        $prts = explode('.', $host);
        $subDomain = array_shift($prts);

        if($subDomain=='mobile') {

            $newName = $name . '_Mobile';

            if($this->has($newName, true, false))
                $name = $newName;

        }

        return parent::get($name, $options, $usePeeringServiceManagers);
    }

}

The definition in our module.config.php:

'service_manager' => array(
    'factories' => array(
        'ViewTemplatePathStack'          => 'Application\Service\Factory\SubDomainBasedTemplatePathStackFactory',
        'ViewTemplateMapResolver'        => 'Application\Service\Factory\SubDomainBasedViewTemplateMapResolverFactory',
        'ControllerLoader'               => 'Application\Service\Factory\SubDomainBasedControllerLoaderFactory',
    ),
),

Attention: If you look at these definitions above, then you realize that we’ve already defined the factories for our mobile view handling: ViewTemplatePathStack and ViewTemplateMapResolver
There are two factories, because we also have to consider any template maps (ViewTemplateMapResolver)!

First the code for the ViewTemplatePathStack factory:

namespace Application\Service\Factory;

use Application\View\Resolver\SubDomainBasedTemplatePathStack;
use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;

class SubDomainBasedTemplatePathStackFactory implements FactoryInterface {

    /**
     * Create service
     *
     * @param ServiceLocatorInterface $serviceLocator
     * @return mixed
     */
    public function createService(ServiceLocatorInterface $serviceLocator)
    {
        $config = $serviceLocator->get('Config');

        $templatePathStack = new SubDomainBasedTemplatePathStack();

        $templatePathStack->setServiceLocator($serviceLocator);

        if (is_array($config) && isset($config['view_manager'])) {
            $config = $config['view_manager'];
            if (is_array($config)) {
                if (isset($config['template_path_stack'])) {
                    $templatePathStack->addPaths($config['template_path_stack']);
                }
                if (isset($config['default_template_suffix'])) {
                    $templatePathStack->setDefaultSuffix($config['default_template_suffix']);
                }
            }
        }

        return $templatePathStack;
    }

}

In the factory above we create the service SubDomainBasedTemplatePathStack:

namespace Application\View\Resolver;

use SplFileInfo;
use Zend\ServiceManager\ServiceLocatorAwareInterface;
use Zend\ServiceManager\ServiceLocatorAwareTrait;
use Zend\View\Exception;
use Zend\View\Exception\DomainException;
use Zend\View\Renderer\RendererInterface as Renderer;
use Zend\View\Resolver\TemplatePathStack;

class SubDomainBasedTemplatePathStack extends TemplatePathStack implements ServiceLocatorAwareInterface {

    use ServiceLocatorAwareTrait;

    /**
     * Retrieve the filesystem path to a view script
     *
     * @param  string $name
     * @param  null|Renderer $renderer
     * @return string
     * @throws \Zend\View\Exception\DomainException
     */
    public function resolve($name, Renderer $renderer = null)
    {
        $this->lastLookupFailure = false;

        if ($this->isLfiProtectionOn() && preg_match('#\.\.[\\\/]#', $name)) {
            throw new DomainException(
                'Requested scripts may not include parent directory traversal ("../", "..\\" notation)'
            );
        }

        if (!count($this->paths)) {
            $this->lastLookupFailure = static::FAILURE_NO_PATHS;
            return false;
        }

        // Ensure we have the expected file extension
        $defaultSuffix = $this->getDefaultSuffix();
        if (pathinfo($name, PATHINFO_EXTENSION) == '') {
            $name .= '.' . $defaultSuffix;
        }

        /** @var \Zend\Uri\Http $uri */
        $uri = $this->getServiceLocator()->get('Request')->getUri();
        $host = $uri->getHost();

        $prts = explode('.', $host);
        $subDomain = array_shift($prts);

        foreach ($this->paths as $path) {

	    // mzeller: THIS IS THE NEW MOBILE CHECK PART
            if($subDomain=='mobile') {
                // check mobile folder first
                $file = new SplFileInfo($path . 'mobile' . DIRECTORY_SEPARATOR . $name);

                #var_dump($path . $appKey . DIRECTORY_SEPARATOR . $name);

                if ($file->isReadable()) {
                    // Found! Return it.
                    if (($filePath = $file->getRealPath()) === false && substr($path, 0, 7) === 'phar://') {
                        // Do not try to expand phar paths (realpath + phars == fail)
                        $filePath = $path . 'mobile' . DIRECTORY_SEPARATOR . $name;
                        if (!file_exists($filePath)) {
                            break;
                        }
                    }
                    if ($this->useStreamWrapper()) {
                        // If using a stream wrapper, prepend the spec to the path
                        $filePath = 'zend.view://' . $filePath;
                    }
                    return $filePath;
                }
            }

            $file = new SplFileInfo($path . $name);

            #var_dump($path . $name);

            if ($file->isReadable()) {
                // Found! Return it.
                if (($filePath = $file->getRealPath()) === false && substr($path, 0, 7) === 'phar://') {
                    // Do not try to expand phar paths (realpath + phars == fail)
                    $filePath = $path . $name;
                    if (!file_exists($filePath)) {
                        break;
                    }
                }
                if ($this->useStreamWrapper()) {
                    // If using a stream wrapper, prepend the spec to the path
                    $filePath = 'zend.view://' . $filePath;
                }
                return $filePath;
            }
        }

        $this->lastLookupFailure = static::FAILURE_NOT_FOUND;
        return false;
    }

} 

Here I had to copy the code from the parent class and to extend with new code. Search for „THIS IS THE NEW MOBILE CHECK PART“.

The other factory was for the ViewTemplateMapResolver:

namespace Application\Service\Factory;

use Application\View\Resolver\SubDomainBasedTemplateMapResolver;
use Zend\Mvc\Service\ViewTemplateMapResolverFactory;
use Zend\ServiceManager\ServiceLocatorInterface;

class SubDomainBasedViewTemplateMapResolverFactory extends ViewTemplateMapResolverFactory {

    /**
     * Create the template map view resolver
     *
     * Creates a Zend\View\Resolver\AggregateResolver and populates it with the
     * ['view_manager']['template_map']
     *
     * @param  ServiceLocatorInterface $serviceLocator
     * @return ClientBasedTemplateMapResolver
     */
    public function createService(ServiceLocatorInterface $serviceLocator)
    {
        $config = $serviceLocator->get('Config');
        $map = array();
        if (is_array($config) && isset($config['view_manager'])) {
            $config = $config['view_manager'];
            if (is_array($config) && isset($config['template_map'])) {
                $map = $config['template_map'];
            }
        }
        return new SubDomainBasedTemplateMapResolver($map, $serviceLocator);
    }

}

In this factory above we create the service SubDomainBasedTemplateMapResolver:

namespace Application\View\Resolver;

use Zend\Console\Request as ConsoleRequest;
use Zend\ServiceManager\ServiceLocatorAwareInterface;
use Zend\ServiceManager\ServiceLocatorAwareTrait;
use Zend\View\Resolver\TemplateMapResolver;

class SubDomainBasedTemplateMapResolver extends TemplateMapResolver implements ServiceLocatorAwareInterface {

    use ServiceLocatorAwareTrait;

    /**
     * Retrieve a template path by name
     *
     * @param  string $name
     * @return false|string
     * @throws \Zend\View\Exception\DomainException if no entry exists
     */
    public function get($name)
    {
        if($this->getServiceLocator()->get('Request') instanceof ConsoleRequest) {
            return parent::get($name);
        }

        /** @var \Zend\Uri\Http $uri */
        $uri = $this->getServiceLocator()->get('Request')->getUri();
        $host = $uri->getHost();

        $prts = explode('.', $host);
        $subDomain = array_shift($prts);

        $subDomainTemplate = null;
        if($subDomain=='mobile')
            $subDomainTemplate = 'mobile/' . $name;

        if($subDomainTemplate!=null && $this->has($subDomainTemplate))
            return $this->map[$subDomainTemplate];

        if (!$this->has($name)) {
            return false;
        }
        return $this->map[$name];
    }

} 

How to define the mobile IndexController from our example above?

'controllers' => array(
        'invokables' => array(
            'Application\Controller\Index' => 'Application\Controller\IndexController',
	    'Application\Controller\Test' => 'Application\Controller\TestController',

            'Application\Controller\Index_Mobile' => 'Application\Controller\Mobile\IndexController',
        )
    ),

In conclusion, an example of how our mobile IndexController might look like:

namespace Application\Controller\Mobile;


use Application\Controller\IndexController as BaseIndexController;
use Zend\View\Model\ViewModel;

class IndexController extends BaseIndexController {

    public function indexAction()
    {
	$parentViewModel = parent::indexAction();
	$parentViewModel->setVariable('specialMobileValue', 'yeah');
        return $parentViewModel;
    }

}

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.