Motivation
I need to be able to redirect referral links that the user shares and track the request. Basically I have a module that generates referrals for every user so that he/she can share the link with his/her friends. Every time the link is clicked, it will record the clickthrough and do something. Things can be add some credit to the user account or reward points to the user account when his/her friend clicks the link and completes the order. It works like an affiliate program. The redirect is just part of the whole picture but it is interesting to know how we can do this in Magento 2.
The problem
The sharable URLs are short URLs like this https://site.com/r/CI8SEN2N where /r/ is something we add say ‘referral’ and ‘CI8SEN2N‘ is the referral id that is created for the user. My module uses a different front end name eg, referral so in the routes.xml I can specify the module front end name as ‘demo’ and all my controller actions are to be under this front end, for example, http://site.com/demo/CONTROLLER/ACTION. I do not want to make the sharable URL long although I can use the same pattern for the sharable URL like this, http://site.com/demo/affiliate/track/referral-link/CI8SEN2N It just look too long. So I need to create a URL redirect dynamically for any sharable URLs which have a pattern like ‘/r/REFERRALID’
The options
Option 1: To create URL redirects in the table url_rewrite which is used for URL rewrites for products and CMS pages. I can add another type of URL redirects for our affiliate referrals. For every link I need to insert a record to the table and as the customer referral grows, the table will grow. I do not like that since the records will have the same pattern and it looks like a bit of duplication to store these records.
Option 2: I can hook into the event system in Magento and insert the URL redirect logic in one of the event observers. This way I do not have to maintain the database table for redirect URLs. I think this option is lightweight and better. So I will go with that.
Implementation
I need to be able to tap into the routing process somehow to add the logic to detect the request URL. In this case, I use the event ‘cms_controller_router_match_before‘ which is emitted in the file ‘vendor/magento/module-cms/Controller/Router.php‘.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| //… | |
| /** | |
| * Validate and Match Cms Page and modify request | |
| * | |
| * @param \Magento\Framework\App\RequestInterface $request | |
| * @return bool | |
| */ | |
| public function match(\Magento\Framework\App\RequestInterface $request) | |
| { | |
| $identifier = trim($request->getPathInfo(), '/'); | |
| $condition = new \Magento\Framework\DataObject(['identifier' => $identifier, 'continue' => true]); | |
| $this->_eventManager->dispatch( | |
| 'cms_controller_router_match_before', | |
| ['router' => $this, 'condition' => $condition] | |
| ); | |
| $identifier = $condition->getIdentifier(); | |
| if ($condition->getRedirectUrl()) { | |
| $this->_response->setRedirect($condition->getRedirectUrl()); | |
| $request->setDispatched(true); | |
| return $this->actionFactory->create('Magento\Framework\App\Action\Redirect'); | |
| } | |
| if (!$condition->getContinue()) { | |
| return null; | |
| } | |
| /** @var \Magento\Cms\Model\Page $page */ | |
| $page = $this->_pageFactory->create(); | |
| $pageId = $page->checkIdentifier($identifier, $this->_storeManager->getStore()->getId()); | |
| if (!$pageId) { | |
| return null; | |
| } | |
| $request->setModuleName('cms')->setControllerName('page')->setActionName('view')->setParam('page_id', $pageId); | |
| $request->setAlias(\Magento\Framework\Url::REWRITE_REQUEST_PATH_ALIAS, $identifier); | |
| return $this->actionFactory->create('Magento\Framework\App\Action\Forward'); | |
| } |
I can add a URL checking logic and redirect the request if I find the URL pattern is ‘/r/xxxx’.
Add an event Observer
In etc/events.xml inside the module, add an observer to listen to the cms_controller_router_match_before event.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?xml version="1.0"?> | |
| <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd"> | |
| <event name="cms_controller_router_match_before"> | |
| <observer name="route_referral_request" instance="\Ming\Demo\Observer\RouteReferralRequest"/> | |
| </event> | |
| </config> |
Class \Ming\Demo\Observer\RouteReferralRequest is the one that handles the logic when the event cms_controller_router_match_before is emitted. This observer class can be any simple PHP class that does not extend any class as long as it implements the Magento\Framework\Event\ObserverInterface which defines only one method execute. This method takes one parameter $observer which is type of \Magento\Framework\Event\Observer. So here is the observer class looks like,
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| namespace Ming\Demo\Observer; | |
| use Magento\Framework\Event\ObserverInterface; | |
| class RouteReferralRequest implements ObserverInterface | |
| { | |
| /** | |
| * @var \Magento\Framework\App\RequestInterface | |
| */ | |
| protected $request; | |
| /** | |
| * @var \Magento\Framework\UrlInterface | |
| */ | |
| protected $url; | |
| /** | |
| * @param \Magento\Framework\UrlInterface $url | |
| * @param \Magento\Framework\App\RequestInterface $request | |
| */ | |
| public function __construct( | |
| \Magento\Framework\UrlInterface $url, | |
| \Magento\Framework\App\RequestInterface $request | |
| ) { | |
| $this->url = $url; | |
| $this->request = $request; | |
| } | |
| /** | |
| * | |
| * @param \Magento\Framework\Event\Observer $observer | |
| * | |
| * @return void | |
| */ | |
| public function execute(\Magento\Framework\Event\Observer $observer) | |
| { | |
| $request = $this->request; | |
| $pathInfo = $request->getPathInfo(); | |
| $identifier = trim($pathInfo, '/'); | |
| $parts = explode('/', $identifier); | |
| if (count($parts) === 2 && $parts[0] === 'r') { | |
| $observer | |
| ->getEvent() | |
| ->getCondition() | |
| ->setRedirectUrl($this->url->getUrl('demo/affiliate/track/referral-link/'.$parts[1])); | |
| } | |
| } | |
| } |
As you can see, in the observer I am able to access the event data that was passed in when this event is dispatched from Magento CMS controller router class. I just get the data object condition and set the redirect URL so that the URL set will be picked up and processed by the CMS router. Also note that this becomes possible because the event data is passed in by reference. So there we go. Magento now can recognise my custom URL path /r/xxxx and pass the request to my real controller action that can handle the tracking before it actually takes the user to the page specified, maybe a campaign page or some CMS page or just the homepage.
Thoughts
This redirect does not render as 30X redirects so the user will not see the URL change in the browser as it just passes the request to another controller action to process. So to the end user, it is transparent just like CMS and product page URLs with pretty SEO friendly URLs.
The CMS router is registered in the Magento CMS module, vendor/magento/module-cms/etc/frontend/di.xml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?xml version="1.0"?> | |
| <!– | |
| /** | |
| * Copyright © 2013-2017 Magento, Inc. All rights reserved. | |
| * See COPYING.txt for license details. | |
| */ | |
| –> | |
| <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> | |
| <type name="Magento\Framework\App\RouterList"> | |
| <arguments> | |
| <argument name="routerList" xsi:type="array"> | |
| <item name="cms" xsi:type="array"> | |
| <item name="class" xsi:type="string">Magento\Cms\Controller\Router</item> | |
| <item name="disable" xsi:type="boolean">false</item> | |
| <item name="sortOrder" xsi:type="string">60</item> | |
| </item> | |
| </argument> | |
| </arguments> | |
| </type> | |
| </config> |
You can see the router is added to the router list and work with other routers including DefaultRoter, BaseRouter which both implements \Magento\Framework\App\RouterInterface with a match method. That means if we like, we can define our own router and define our own routing rules. We just need to add our router to the router list in our di.xml file so Magento can use our custom router to match incoming requests. Well this could be the option 3 I missed out in there but I think for this use case, it is a bit overkill. If we have a big module that requests a very special routing, then this could be a good solution. Again, the less code the less bugs. So keep it light:)