Skip to main content

Architecting a multi-country site with Drupal 7

Filed Under: Drupal, Drupal Planet

Recently at Bluespark we had the opportunity to create a multi-country site to support Johnson & Johnson’s Splenda™ launch in Latin America. One of the main requirements we had was to enable the selective publication of content across all countries or for just a specific Latin American country. For example, all the Frequently Asked Questions are common to all the countries available on the site, so we want to publish one time and make this content visible for all countries; but there is also some content, such as News and Events, that we want to display only for specific countries.

At its heart, this is a relatively common requirement for Drupal: the need to display content based on some criteria. Fortunately, Drupal offers a number of different ways to resolve this. Below we discuss the different approaches we evaluated for this project and the reasoning behind our final decision.

The possible architectural approaches we considered for this project included:

Using Drupal’s in-built multi-site feature with different domain names

Each country, as a separate site, would run on the same codebase and use the same theme, but have a distinct database, with individual content. Common configurations would be exported to code as features and then per-site configuration overrides could be handled either in the database or in the country-specific settings file at sites/site.example.con/settings.php. While it is true that certain tables could be shared between sites, such as the user related tables, the general problem with this model is that sharing content between the sites is complicated.

Using Domain Access

This is a powerful module that allows multiple subdomains to co-exist within the same Drupal instance, and for every piece of content in the system, it provides a special field that allows you to define which domain(s) the content can be “affiliated” with. Content can belong to one single domain or to multiple subdomains. Domain Access then uses Drupal node access configurations to ensure that each and every request for a node page (e.g. node/1) is only accessible at the domain(s) to which it belongs.

There are many caveats to the Domain Access module’s architecture because, by itself, it lacks support for some basic modules that almost all sites require (e.g. Context, Boost, SecurePages, AuthCache, etc.). All these modules require some path configuration; e.g., a Context will normally be conditioned to be activated in some paths, but with Domain Access every subdomain represents a different Country content but the path for a page will remain the same. Taking an example for news page we can have multiple URLS: mexico.mysite.com/news or colombia.mysite.com/news but this modules will not understand differences of content based in our subdomain parameter and extra modules as Domain Context will be required to resolve this issue. The same problem will arise with all the modules that condition them behavior based on path criteria). Even Drupal core does not recognize the domains as a context for paths conditionally showing blocks.

Very different will be with OG having URLS like: mysite.com/mexico/news or mysite.com/colombia/news the paths criteria could determine country and page without extra dependencies. However, in several cases there are companion adapter modules, such as Domains Views, that make it possible to filter content (in this case, by way of a contextual argument) that correspond to some specific domain. Simply put, Drupal is not designed to understand multiple domains as different pages; what Drupal understands well are URIs (relative paths) to your domain. Domain Access has a tendency to end up being a complicated module to understand requiring a plethora of add-on modules and configurations to handle the conditional content display on a per-domain basis for each country-specific feature you add to your sites.

The third approach we evaluated and finally went with is based on the popular module OG (Organic Groups)

OG was used to great effect within the intranet distribution Open Atrium. In the following paragraphs we explain in more detail how we employed the OG module.

Resolving multi-country feature with OG

The major point to consider here is that content classification is very easy with Organic Groups -- which after all is one of the primary uses of this module -- allowing groupings of users and content. The first step is to create the content types:

  1. Country
  2. Event
  3. News
  4. Article
  5. Product

For this project, Events and News are considered country-specific content, and Articles and Products are general content visible in all of the countries. The strategy to achieve this is straightforward:

  1. The Country content type only contains a title, a body, and a country field -- use the Countries module to get a dropdown selector of the ISO code for every country. We define this content type as the definitive “Group type” for Organic Groups configuration, as the Country is the basis upon which we are grouping most content. At this point, we can set up the actual countries we want to serve, using this content type (node/1 ends up being México, node/2 = Argentina, etc.).
  2. For the country-specific content types, we add a field of type “Group Audience.” This creates a reference between the content and the Country OG.
  3. Finally, we create the individual pages to display content for specific countries, e.g. News for México, Argentina, Ecuador, etc. For this we create a View that filters by content type News ordered by date desc, and the trick is to add the Contextual Filter OG (gid) that is the ID of our country. This way if we give our view a path like /news, it will receive a second argument for the country which filters the results, e.g. /news/1 (where “1” corresponds to our country, e.g. México).

This configuration is repeated for all of the country-specific content types, and we have resolved the first problem.

Of course, at this point, some questions may arise. You’re probably wondering things like:

  1. How to manage the navigation for country-specific items when the menu items in Drupal can handle only a static path. So if I have a “News” link in my menu, for example, to which country will I link?
  2. How to preserve the context of a country when a user is navigating through the content of a specific country; e.g., how do we preserve a consistent URL structure for all Argentina content?
  3. How to convert the ugly news/1, news/2, news/3 URLs to be SEO friendly.

Implementing dynamic menu items with Menu Token

We need to show different links to our country-specific content views, based on the context of a country that the user is navigating. So imagine that our site has a navigation tree like:

  1. Home - /home/1 (México Home), /home/2 (Argentina Home)
  2. News - /news/1 (México News), /news/2 (Argentina News)
  3. Articles - /articles (All Countries news)

With the custom menus of Drupal we can only associate a single link with every menu item, so it is not possible to create a Home entry point that dynamically changes the ID parameter of the country. With Menu Tokens, however, we can link Home in our example with /news/[node:nid], where [node:nid] is the dynamic part that will be replaced by the ID of a node.

Don’t rejoice yet, though! Menu Tokens don’t offer, out of the box, a replacement that will work for us. We only have: random node (pick randomly a node), user specific (static node configured), and Node from context (the viewed node). As we want to display our menu in a sidebar of a page that is actually a view, we don’t have the required node information in context and none of the available options will work for us.

Fortunately, we can implement our own Menu Token plug-in that has its own logic to provide the node that will be used for the replacement. So in our case we allow the user to choose a Country when accessing our multi-country site, and we store that selected country in a $_SESSION variable. We implement a Menu Token plug-in, as so:

 class menu_token_node_country_selected implements menu_token_handler {
   function object_load($options) {
      selected country
     if (isset($_SESSION['selected_country_id'])) {
       return node_load($_SESSION['selected_country_id']);
     }
     return FALSE;
   }
 }

What we are doing here is providing, as a Menu Token, the Country ID that the user selected at the start. We get the value from the session, load the node, and return the replacements in our menus!

Next, we are going to delve a little deeper into how we can better keep the context of the country and what some of the complexities are around that.

Preserving the context of the country the user is browsing

When a user comes to your site you need a way to remember which country the user is interested in, so as to be able to build the dynamic menus and other country-specific items on the page. This poses some challenges.

While the user could come directly to the home page allowing you to display a select country page, as discussed above, a lot of traffic is actually expected to arrive from search engines and social networks.

For example, if a user is arriving from http://mysite.com/news/mexico, it would be counter-intuitive to take them to a Country select page. Instead, we need to set and preserve the context to México.

Alternatively, we may want to auto-detect the country based on the IP location from the user. This could be done via Smart IP.

In order to flexibly deal with a number of different heuristics, we use Rules and the _multi_country_set_selected_country function (shown below) is called from Rules actions. This allows us via the UI to configure different options on how to set the country context.

Here is the code that Rules calls to set the content:

 /**  
  * Set a selected country in the user session
  *
  * @param int $country_id
  *   the country entity id
  */
 function _multi_country_set_selected_country($country_id = NULL) {
   // if no argument passed return the current selected_country_id value
   if (!$country_id) {
     $selected_country_id = isset($_SESSION['selected_country_id']) ? $_SESSION['selected_country_id'] : NULL;
     return $selected_country_id;
   }
   // avoid set country if set country_id is the same we have in session
   if (isset($_SESSION['selected_country_id']) && $_SESSION['selected_country_id'] == $country_id) {
     return;
   }
 
   // store the OG nid in session
   $_SESSION['selected_country_id'] = $country_id;
 }

Beautify your ugly and poor SEO URLs

The last issue we will deal with in this post is how to avoid displaying any link with unnecessary numerical IDs in the different country pages. We use a lot of views and the contextual filters only understand numerical IDs -- the same will happen if you use Panels -- so the question is how can we create SEO-friendly URLs.

There is an easy answer: create URL aliases for all the possible arguments of your views, panels, etc. However, if you have 20 countries and with the possibility of more to come, this approach is not practical.

The solution here is actually based on an enabling good practice. One of the best practices we use at Bluespark is to have a unique feature for every content type and add all its components inside that feature. For this example site we have:

  1. ct_article – feature for articles
  2. ct_news – feature for news
  3. ct_event – feature for events

This means that we have a module for every content type, and we also have a view filtered by country for every one that will also be contained in its own feature.

With this architecture in place we can easily connect with APIs from other modules (such as Pathauto), so we can integrate custom Pathauto patterns for our content types such as news/1, news/2, etc. or events/1, events/2, etc. Implementing a hook_pathauto() in every content type feature will allow for custom configuration of the auto pattern URL of our content:

 /**
  * Implements hook_pathauto()
  */
 function ct_article_pathauto($op) {
   if ($op == 'settings') {
     $settings = array();
     $settings['module'] = 'ct_article';
     $settings['token_type'] = 'term';
     $settings['groupheader'] = t('Article Panel Page path');
     $settings['patterndescr'] = t('Default path pattern for articles
panel.');
     $settings['patterndefault'] = 'style/[term:name]';
     return (object) $settings;
   }
 }

In this example our Articles page is filtered by categories (taxonomy terms), so we want our view URL (which is actually articles/1, articles/2, etc.) to be displayed as style/tips, style/health, etc. We provide a default pattern via Pathauto style/[term:name], where [term:name] will be replaced by the term name of every new category added, updated, or deleted. Reacting on these events and calling the function pathauto_create_alias allows us to dynamically add a URL alias for any possible URL for our View that we know is composed by different taxonomy terms of a specific vocabulary.

So we also implement in our ct_article.module the following hooks:

  1. hook_taxonomy_term_insert() - call pathauto_create_alias
  2. hook_taxonomy_term_update() - call pathauto_create_alias
  3. hook_taxonomy_term_delete() - call pathauto_entity_path_delete_all

The same implementation will be adapted to all our different content types, creating on-the-fly all the aliases that we need for our custom pages (views, panels, or custom modules).

Done! We now have a complete solution for a multi-country site with a relatively simple approach, using a small number of modules whilst following the Drupal principles of reuse and override. We hope this will be useful for people out there. Please share your experience and comments on similar problems with us!

We'd love to partner with you on your next project!

Since we’re big on relationships, we’re all about finding the right fit. Will you take the next step with us to see if we’re a match?