How to use UrlManager to configure routing and create “friendly” URLs


    Hello dear readers! I continue the series of articles on how we developed an atypical, large project using the Yii2 framework and AngularJS.

    In a previous article, I described the benefits of our chosen technology stack and proposed a modular architecture for our application .

    This article will focus on setting up routing and creating URLs using urlManager for each module individually. I will also sort through the process of creating your own rules for specific URLs by writing a class that extends UrlRuleInterface. In conclusion, I will describe how we implemented the generation and output of meta tags for public pages of the site.

    The most interesting thing under the cut.

    URL Rules


    I assume that you most likely already used UrlManager earlier, at least in order to enable the CNC and hide index.php from the URL.

    //...
    'urlManager' => [
        'class' => 'yii\web\UrlManager',
        'enablePrettyUrl' => true,
        'showScriptName' => false,
    ],
    /..
    

    But UrlManager can do much more than that. Beautiful URLs have a big impact on your site’s search engine results. It is also necessary to consider that you can hide the structure of your application by defining your own rules.

    'enableStrictParsing' => true is a very useful property that restricts access only to rules that are already configured. In the configuration example, the route www.our-site.com will point to site / default / index, but www.our-site.com/site/default/index will display page 404.

    //...
    'urlManager' => [
        'class' => 'yii\web\UrlManager',
        'enablePrettyUrl' => true,
        'showScriptName' => false,
        'enableStrictParsing' => true,
        'rules' => [
            '/' => 'site/default/index',
        ],
    ],
    /..
    

    Since we divided the application into modules and want these modules to be as independent as possible, URL rules can be added dynamically to the URL manager. This will make it possible to distribute and reuse modules without having to configure UrlManager, because the modules will manage their own URL rules.

    In order for dynamically added rules to take effect during the routing process, you must add them at the self-configuration stage. For modules, this means that they must implement yii \ base \ BootstrapInterface and add rules in the bootstrap () boot method, as follows:

    getUrlManager()->addRules(
                [
                    // объявление правил здесь
                    '' => 'site/default/index',
                    '<_a:(about|contacts)>' => 'site/default/<_a>'
                ]
            );
        }
    }
    

    We add the Bootstrap.php file with this code to the module folder / modules / site /. And we will have such a file in every module that will add its own Url rules.

    Note that you must also list these modules in yii \ web \ Application :: bootstrap () so that they can participate in the self-tuning process. To do this, list the modules in the bootstrap array in the /frontend/config/main.php file:

    //...
        'params' => require(__DIR__ . '/params.php'),
        'bootstrap' => [
            'modules\site\Bootstrap',
            'modules\users\Bootstrap',
            'modules\cars\Bootstrap'
            'modules\lease\Bootstrap'
            'modules\seo\Bootstrap'
            ],
    ];
    

    Please note that since the writing of the first article, I have added a few more modules:

    • modules / users - The module in which operations with the user and all pages of the user will be processed (registration, login, personal account)
    • modules / cars - A module in which they will work with a database of brands, brands, and modifications of cars.
    • modules / lease - The module in which ads added by users will be processed.
    • modules / seo - Module for SEO. All components and helpers will be stored here, which will help us meet SEO requirements. I will write about them further.

    Custom URL Rules


    Despite the fact that the standard class yii \ web \ UrlRule is flexible enough for most projects, there are situations when you must create your own rule classes.

    For example, on a car dealer’s website, you can support a URL format like / new-lease / state / Make-Model-Location / Year, where state, Make, Model, Year and Location must correspond to some data stored in the base table data. The default class will not work here, since it relies on statically declared patterns.

    Let's digress a bit from the code, and I will describe to you the essence of the task that confronted us.

    According to the specification, we needed to make the following types of pages with the corresponding rules for generating Url and meta tags:

    Search Results Pages


    They, in turn, are divided into three types:

    Ads from dealers
    url: / new-lease / (state) / (Make) - (Model) - (Location)
    url: / new-lease / (state) / (Make) - (Model) - (Location) / (Year)

    Custom Ads:
    url: / lease-transfer / (state) / (Make) - (Model) - (Location)
    url: / lease-transfer / (state) / (Make) - (Model) - (Location) / (Year)

    For example: / new-lease / NY / volkswagen-GTI-New-York-City / 2015

    Search results when the location is not specified in the filter:
    / (new-lease | lease-transfer) / (Make) - (Model) / ( year)

    Title: (Make) (Model) (Year) for Lease in (Location). (New Leases | Lease Transfers)
    For example: Volkswagen GTI 2015 for Lease in New York City. Dealer Leases.
    Keywords: (Make), (Model), (Year), for, Lease, in, (Location), (New, Leases | Lease, Transfers)
    Description: List of (Make) (Model) (Year) in (Location) available for lease. (Dealer Leases | Lease Transfers).

    Ad Review Page


    They, in turn, are divided into two types:

    Dealer announcement:
    url: / new-lease / (state) / (make) - (model) - (year) - (color) - (fuel type) - (location) - (id)

    Custom declaration:
    url: / lease-transfer / (state) / (make) - (model) - (year) - (color) - (fuel type) - (location) - (id)

    Title: (make) - (model) - (year) - (color) - (fuel type) for lease in (location)
    Keywords: (year), (make), (model), (color), (fuel type) , (location), for, lease
    Description: (year) (make) (model) (color) (fuel type) for lease (location)

    Vehicle Information Pages


    url: / i / (make) - (model) - (year)
    Title: (make) - (model) - (year)
    Keywords: (year), (make), (model)
    Description: (year), (make ), (model)

    This is only part of the pages that we needed to implement. An excerpt from the terms of reference is given here so that you understand what we need to achieve from the Url manager and how non-trivial the rules are.

    Yii2 allows you to define custom URLs through parsing and URL generation logic by making a custom UrlRule class. If you want to make your own UrlRule you can either copy the code from yii \ web \ UrlRule and expand it or, in some cases, just implement yii \ web \ UrlRuleInterface.

    Below is the code that our team wrote for the URL structure we discussed. For him, we created the file /modules/seo/components/UrlRule.php. I do not consider this code as a standard, but I am sure that it unambiguously fulfills the task.

    url = str_replace(' ', '_', substr($params['url'],1) );
                        $route->route = 'lease/search/index';
                        $route->params = json_encode(['make'=>$params['make'], 'model'=>$params['model'], 'location'=>$params['location']  ]);
                        $route->save();
                        return '/'.$params['url'];
                    }
                }
                if (isset($params['type']) && in_array($params['type'], ['user','dealer'])) {
                    $type = ($params['type'] == 'dealer')? 'new-lease' : 'lease-transfer';
                } else {
                    return false;
                }
                if ((isset($params['zip']) && !empty($params['zip'])) || (isset($params['location']) && isset($params['state']))) {
                    // make model price zip type
                    if (isset($params['zip']) && !empty($params['zip'])) {
                        $zipdata = Zip::findOneByZip($params['zip']);
                    } else {
                        $zipdata = Zip::findOneByLocation($params['location'], $params['state']);
                    }
                    // city state_code
                    if (!empty($zipdata)) {
                        $url = $type . '/' . $zipdata['state_code'] . '/' . $params['make'] . '-' . $params['model'] . '-' . $zipdata['city'];
                        if (!empty($params['year'])) {
                            $url.='/'.$params['year'];
                        }
                        $url = str_replace(' ', '_', $url);
                        if($search_url = Route::findRouteByUrl($url)) {
                            return '/'.$url;
                        } else {
                            $route = new Route();
                            $route->url = str_replace(' ','_',$url);
                            $route->route = 'lease/search/index';
                            $pars = ['make'=>$params['make'], 'model'=>$params['model'], 'location'=>$zipdata['city'], 'state'=>$zipdata['state_code'] ]; //, 'zip'=>$params['zip'] ];
                            if (!empty($params['year'])) {
                                $pars['year']=$params['year'];
                            }
                            $route->params = json_encode($pars);
                            $route->save();
                            return $route->url;
                        }
                    }
                }
                if (isset($params['make'], $params['model'] )) {
                    $url = $type . '/' . $params['make'] . '-' . $params['model'] ;
                    if (!empty($params['year'])) {
                        $url.='/'.$params['year'];
                    }
                    $url = str_replace(' ', '_', $url);
                    if($search_url = Route::findRouteByUrl($url)) {
                        return '/'.$url;
                    } else {
                        $route = new Route();
                        $route->url = str_replace(' ','_',$url);
                        $route->route = 'lease/search/index';
                        $pars = ['make'=>$params['make'], 'model'=>$params['model']  ];
                        if (!empty($params['year'])) {
                            $pars['year']=$params['year'];
                        }
                        $route->params = json_encode($pars);
                        $route->save();
                        return $route->url;
                    }
                }
            }
            return false;
        }
        /**
         * Parse request
         * @param \yii\web\Request|UrlManager $manager
         * @param \yii\web\Request $request
         * @return array|boolean
         */
        public function parseRequest($manager, $request)
        {
            $pathInfo = $request->getPathInfo();
            /**
             * Parse request for search URLs with location and year
             */
            if (preg_match('%^(?Please-transfer|new-lease)\/(?P[A-Za-z]{2})\/(?P[._\sA-Za-z-0-9-]+)\/(?P\d{4})?%', $pathInfo, $matches)) {
                $route = Route::findRouteByUrl($pathInfo);
                if (!$route) {
                    return false;
                }
                $params = [
                    'node' => $matches['url'] . '/' . $matches['year'],
                    'role' => $matches['role'],
                    'state' => $matches['state'],
                    'year' => $matches['year']
                ];
                if (!empty($route['params'])) {
                    $params = array_merge($params, json_decode($route['params'], true));
                }
                return [$route['route'], $params];
            }
            /**
             * Parse request for search URLs with location and with year
             */
            if (preg_match('%^(?Please-transfer|new-lease)\/(?P[._\sA-Za-z-0-9-]+)\/(?P\d{4})%', $pathInfo, $matches)) {
                $route = Route::findRouteByUrl($pathInfo);
                if (!$route) {
                    return false;
                }
                $params = [
                    'node' => $matches['url'] . '/' . $matches['year'],
                    'role' => $matches['role'],
                    'year' => $matches['year']
                ];
                if (!empty($route['params'])) {
                    $params = array_merge($params, json_decode($route['params'], true));
                }
                return [$route['route'], $params];
            }
            /**
             * Parse request for leases URLs and search URLs with location
             */
            if (preg_match('%^(?Please-transfer|new-lease)\/(?P[A-Za-z]{2})\/(?P[_A-Za-z-0-9-]+)?%', $pathInfo, $matches)) {
                $route = Route::findRouteByUrl([$matches['url'], $pathInfo]);
                if (!$route) {
                    return false;
                }
                $params = [
                    'role' => $matches['role'],
                    'node' => $matches['url'],
                    'state' => $matches['state']
                ];
                if (!empty($route['params'])) {
                    $params = array_merge($params, json_decode($route['params'], true));
                }
                return [$route['route'], $params];
            }
            /**
             * Parse request for search URLs without location and year
             */
            if (preg_match('%^(?Please-transfer|new-lease)\/(?P[._\sA-Za-z-0-9-]+)?%', $pathInfo, $matches)) {
                $route = Route::findRouteByUrl($pathInfo);
                if (!$route) {
                    return false;
                }
                $params = [
                    'node' => $matches['url'],
                    'role' => $matches['role'],
                ];
                if (!empty($route['params'])) {
                    $params = array_merge($params, json_decode($route['params'], true));
                }
                return [$route['route'], $params];
            }
            /**
             * Parse request for Information pages URLs
             */
            if (preg_match('%^i\/(?P[_A-Za-z-0-9-]+)?%', $pathInfo, $matches)) {
                $route = Route::findRouteByUrl($matches['url']);
                if (!$route) {
                    return false;
                }
                $params = Json::decode($route['params']);
                $params['node'] = $route['url'];
                return [$route['route'], $params];
            }
            return false;
        }
    }
    

    In order to use it, you just need to add this class to the array of rules yii \ web \ UrlManager :: $ rules.

    To do this, create the Bootstrap.php file in the / modules / seo module (similar to the Bootstrap.php file in the / modules / site module) and declare the following rule in it:
    //...
        public function bootstrap($app)
        {
            $app->getUrlManager()->addRules(
                [
                    [
                      'class' => 'modules\seo\components\UrlRule,
                    ],
                ]
            );
        }
    /..
    

    This special rule is for a very specific use case. We do not plan to reuse this rule in other projects, so it has no settings.

    Since the rule is generally not configurable, there is no need to extend from yii \ web \ UrlRule, yii \ base \ Object, or from anything else. Just implementing the yii \ web \ UrlRuleInterface interface is enough. Because we do not plan to reuse this rule in our reused modules, we defined it in the SEO module.

    parseRequest () looks at the route, and if it matches the regular expression in the condition, it parses further to retrieve the parameters.

    In this method, we use the Route helper model, in which generated links are stored in the url field. They search for compliance with the findRouteByUrl method. This method returns us one record from the table (if there is one) with the fields:

    • url - part of the search query by which the entry was found,
    • route - the route to which control should be transferred,
    • params - additional, parameters in JSON format, strings that need to be passed into action for further work.

    parseRequest () returns an array with the action and parameters:

    [
                ‘lease/search/view’,
                [
                    'node' => new-lease/NY/ volkswagen-GTI-New-York-City/2016,
                    'role' => ‘new-lease’,
                    'state' => ‘NY’,
                        'year' => ‘2016’
                ]
    ]
    

    Otherwise, returns false to indicate to UrlManager that it cannot parse the request.

    createUrl () builds the URL from the provided parameters, but only if the URL was suggested for the actions lease / lease / view, cars / info / view or lease / search / view.

    For performance reasons


    When developing complex web applications, it’s important to optimize URL rules so that parsing requests and creating URLs takes less time.

    When parsing or creating a URL, the URL manager parses the URL rules in the order in which they were declared. Thus, you might consider adjusting the order of the URL rules so that more specific and / or frequently used rules are placed before the less used rules.

    It often happens that your application consists of modules, each of which has its own set of URL rules with module ID, as well as their common prefix.

    Meta tag generation and output


    In order to generate and output meta tags in a specific format for the specified page types, a special helper was written, which is located in the file modules / seo / helpers / Meta.php. It contains the following code:

    view->registerMetaTag(['name' => 'keywords','content' => 'lease, car, transfer']);
                    Yii::$app->view->registerMetaTag(['name' => 'description','content' => 'Carvoy - Change the way you lease! Lease your next new car online and we\'ll deliver it to your doorstep.']);
                    break;
                case 'lease':
                    $title = $model->make . ' - ' . $model->model . ' - ' . $model->year . ' - ' . $model->exterior_color . ' - ' . $model->engineFuelType . ' for lease in ' . $model->location;
                    Yii::$app->view->registerMetaTag(['name' => 'keywords','content' => Html::encode($model->year . ', ' . $model->make . ', ' . $model->model . ', ' . $model->exterior_color . ', ' . $model->engineFuelType . ', ' . $model->location . ', for, lease')]);
                    Yii::$app->view->registerMetaTag(['name' => 'description','content' => Html::encode($model->year . ' ' . $model->make . ' ' . $model->model . ' ' . $model->exterior_color . ' ' . $model->engineFuelType . ' for lease in ' . $model->location)]);
                    break;
                case 'info_page':
                    $title = $model->make . ' - ' . $model->model . ' - ' . $model->year;
                    Yii::$app->view->registerMetaTag(['name' => 'keywords','content' => Html::encode($model->year . ', ' . $model->make . ', ' . $model->model)]);
                    Yii::$app->view->registerMetaTag(['name' => 'description','content' => Html::encode($model->year . ' ' . $model->make . ' ' . $model->model)]);
                    break;
                case 'search':
                    if ($model['role'] == 'd') $role = 'Dealer Lease';
                    elseif ($model['role'] == 'u') $role = 'Lease Transfers';
                    else $role = 'All Leases';
                    if (isset($model['make']) && isset($model['model'])) {
                        $_make = (is_array($model['make']))? (( isset($model['make']) && ( count($model['make']) == 1) )? $model['make'][0] : false ) : $model['make'];
                        $_model = (is_array($model['model']))? (( isset($model['model']) && ( count($model['model']) == 1) )? $model['model'][0] : false ) : $model['model'];
                        $_year = false;
                        $_location = false;
                        if (isset($model['year'])) {
                            $_year = (is_array($model['year']))? (( isset($model['year']) && ( count($model['year']) == 1) )? $model['year'][0] : false ) : $model['year'];
                        }
                        if (isset($model['location'])) {
                            $_location = (is_array($model['location']))? (( isset($model['location']) && ( count($model['location']) == 1) )? $model['location'][0] : false ) : $model['location'];
                        }
                        if ( ($_make || $_model) && !(isset($model['make']) && ( count($model['make']) > 1)) ) {
                            $title = $_make . (($_model)? ' ' . $_model : '') . (($_year)? ' ' . $_year : '') . ' for Lease' . (($_location)? ' in ' . $_location . '. ' : '. ') . $role . '.';
                        } else {
                            $title = 'Vehicle for Lease' . (($_location)? ' in ' . $_location . '. ' : '. ') . $role . '.';
                        }
                        Yii::$app->view->registerMetaTag(['name' => 'keywords','content' => Html::encode( ltrim($_make . (($_model)? ', ' . $_model : '') . (($_year)? ', ' . $_year : '') . ', for, Lease' . (($_location)? ', in, ' . $_location : '') . ', ' . implode(', ', (explode(' ', $role))), ', ') ) ]);
                        Yii::$app->view->registerMetaTag(['name' => 'description','content' => Html::encode( 'List of '. ((!$_model && !$_make)? 'Vehicles' : '') . $_make . (($_model)? ' ' . $_model : '') . (($_year)? ' ' . $_year : '') . (($_location)? ' in ' . $_location : '') . ' available for lease. ' . $role . '.' )]);
                    } else {
                        $title = 'Search results';
                    }
                    break;
            }
            return $title;
        }
    }
    

    We use this helper in the view of the page for which you need to set meta tags. For example, for the ad review page, add the following line to the /modules/lease/views/frontend/lease/view.php file

    //...
        $this->title = \modules\seo\helpers\Meta::all('lease', $model);
    /..
    

    The first parameter to the method is the type of page for which meta tags are generated. The second parameter passes the model of the current ad.

    Inside the method, meta tags are generated depending on the type of page and added to the head using the registerMetaTag method of the yii \ web \ View class. The method returns us the generated string for the title tag. Thus, through the $ title property of the yii \ web \ View class, we set the page title.

    Thanks for attention!

    Material prepared by: greebn9k (Sergey Gribnyak), pavel-berezhnoy (Pavel Berezhnoy), silmarilion (Andrey Khakharev)

    Also popular now: