Localization & Multi-Environment Setup in Craft.

,

So I’m nearing the end of my first real-life localized Craft CMS site, and thought I’d document the process a bit. Mostly for my own sake — so I can do this quicker/better next time around; — but also for anyone else who might find it useful.

This is not a definitive guide. It’s just the way I did it this time around, and next time I may do it completely differently. There are a number of good approaches to implementing multi-environment localized sites in Craft, and I’d certainly like to hear from you if you have any suggested improvements.

The website in question is a redesign of Medics Away (in last stages of development at writing but launching soon), and it will be available in English, French, Spanish, German, Italian and Swedish.

Localization & Multi-Environment Setup in Craft on DesignKarma
Medics Away website by DesignKarma, available in multiple languages.

Craft ist Wunderbar!

First off I’d like to say what a delight it was to do a localized site in Craft. Having wrestled with this stuff in other CMS’ over the years I was really surprised how straightforward it was. Equally, the client seems to have had no trouble getting stuck into content translations for – in this instance – 6 languages. There’s a decent guide in the Craft docs on setting up a localized site so I’m not going to dwell on that. Instead I’ll share my setup for anyone who’s new to this.

One suggestion I have right off the bat is to get yourself a copy of Bob Olde Hampsink’s Translate plugin. Craft suggests using translation files for hard-coded things like button labels. That’s all fine but it’s still ’content’ in my opinion, so needs to be easily editable by the client and belongs in the CMS. Bob’s Translate plugin lets you do just that. Check it out.

Domain Registration

The Craft docs use different URLs in their localization examples (e.g. http://example.com/es/). But for various reasons we wanted each locale to have its’ own domain with native TLDs (e.g. http://example.es, http://example.fr etc…). So before we did anything else we needed to register all our domains.

Now, registrars are like banks – they’re all shit. But some are less shit than others. I’ve used Fasthosts for years, and more recently Namecheap and Name.com, and they’re fine. It’s not easy to find a registrar that can register all your foreign domains though, so you’ll probably wind up with your domains scattered over two or three registrars. Not a major issue but it’s less than ideal and something you’ll need to explain to your client. Also be aware that for legal reasons some TLDs require corporate or personal identification during registration, so have your passport handy.

Multi-Environment Setup

Now we have our domains it’s time to setup our local, staging and live environments.

A year or so ago I joined in the collective community leg-humping of Digital Ocean and ServerPilot, and I must say they’re really rather good. So that’s the setup I chose for Medics Away staging and live environments. Locally I’m using MAMP Pro. I know, I know… I’ve tried virtual ’boxes’ but MAMP does a fine job, and I can’t stomach the learning curve of something like Vagrant just now. Don’t look at me like that!

Digital Ocean lets you ’spin up’ droplets in different regions (New York, Amsterdam, Frankfurt etc…) so it can be beneficial from an SEO standpoint to have multiple droplets since you’ll be serving your localized sites from more regional IP addresses. There’s obviously added cost in this approach though and I suppose it’s debatable how much SEO benefit you’ll get. In my case we needed to keep a lid on costs and just went with everything on the same droplet.

I won’t go into creating a Digital Ocean droplet here, or how to hook up ServerPilot, because there are plenty of good guides on that already. However there are some things to consider when you create your first ServerPilot "App" (website). To create a localized Craft site with different domains on DO/SP it’s best to use the 3rd example in Craft’s Localization Guide, with each locale getting its’ own directory and index.php. So on ServerPilot this means each locale needs its’ own App. My Apps look like:

medicsawaycom/
	public/
medicsawayde/
	public/
medicsawayes/
	public/
etc...

Naming Your Apps

Be careful when naming your default App on ServerPilot. Where you have multiple Apps on the same server, ServerPilot will treat the default App as the app whose name is first alphabetically. This default App is the one that will be used for any web requests that use the server’s IP address or a domain that doesn’t belong to any of the other Apps. So if you have two Apps on your server, foo and bar, then the App bar will be the default. In my case the number of locales is fixed and there are no plans to add more, so I was able to just name my apps medicsawaycom, medicsawayde, medicsawayes etc… and the ’com’ app would always be first and default. A default App named 0default would make more sense though.

Choose the same server and user for each App as your default one so you won’t run into any permission issues.

Your default App is where your Craft installation lives. All other Apps just contain a public directory so make sure you upload the .htaccess and index.php files (from Craft’s public/ folder) with a couple of edits into each of those Apps.

// Path to your craft/ folder
$craftPath = '../../craft';

// Tell Craft to serve the German content
define('CRAFT_LOCALE', 'de');

Replicate this structure on your local environment and you’re ready to config.

Let’s Config

Another plug here for Craft’s Multi-Environment setup which will work just fine for your localized DO/SP site. In my case though I’ve got 6 languages and 3 environments, so my general.php file was getting pretty lengthy. I was also itching for an opportunity to use nystudio107’s nifty Craft-Multi-Environment config (CME) for the first time, so this seemed like a good opportunity.

.env.php

“CME works by including a .env.php file (which is never checked into git) via the Craft index.php file that is loaded for every non-static request.”

“The .env.php file sets some globally-accessible settings via putenv() for common things like the database password, database user, base URL, etc. You’re also free to add your own as you see fit. The craft/config/general.php and craft/config/db.php can thus remain abstracted, and each environment can have their own local settings.”

Now each locale can have its’ own .env.php (in the root above public) and everything else just lives in my default site’s craft/app/config/db.php and craft/app/config/general.php. Here’s how it looks:

medicsawaycom/
	craft/
		app/
		config/
			db.php
			general.php
			etc...
	.env.php
	public/
		index.php
medicsawayde/
	.env.php
	public/
		index.php
medicsawayes/
	.env.php
	public/
		index.php
etc...

Each locale’s .env.php gives me the flexibility to set and share $craftEnvVars across all environments for all locales (local, staging and live). These vars include the standard CME ones like SITE_URL and BASE_PATH but also some custom vars I’ve added for each locale:

'IS_SYSTEM_ON_LIVE' => false,
'SITE_NAME' => 'Medics Away',
'TIMEZONE' => 'Europe/London'

The IS_SYSTEM_ON_LIVE variable is especially important because I need the ability to set 'isSystemOn' for individual locales rather than all of them, because some aren’t ready just yet. These separate .env.php $craftEnvVars will be useful going forwards too if I ever need to add new variables on a per-locale basis.

The most important $craftEnvVars variable though is 'CRAFT_ENVIRONMENT' (live, stage and local). My domain naming convention on this project is http://local.medicsaway.dev, http://local.medicsaway.dev.de etc… for local development; https://stage.medicsaway.com, https://stage.medicsaway.de etc… for staging; and obviously regular domains (without a subdomain) for live. So now the following code will set a $currentEnvironment var:

// Parse URL and get subdomain
$url = $protocol . $_SERVER['HTTP_HOST'];
$parsedUrl = parse_url($url);
$host = explode('.', $parsedUrl['host']);
$subdomain = $host[0];

// Determine the environment based on subdomain
if ($subdomain === 'local') {
    $currentEnvironment = 'local';
} elseif ($subdomain === 'stage') {
    $currentEnvironment = 'stage';
} else {
    $currentEnvironment = 'live';
}

… Which then gets added to my $craftEnvVars array:

'CRAFT_ENVIRONMENT' => $currentEnvironment,

Still with me? Okay on to more familiar territory now with craft/app/config/db.php.

db.php

<?php
return array(

    // All environments
    '*' => array(
        'tablePrefix' => 'craft',
        'server' => 'localhost',
        'database' => 'xxxxxxxxxxxx',
        'user' => 'xxxxxxxxxxxx',
        'password' => 'xxxxxxxxxxxx'
    ),

    // Live (production) environment
    'live' => array(
    ),

    // Staging (pre-production) environment
    'stage' => array(
    ),

    // Local (development) environment
    'local' => array(
        'database' => 'xxxxxxxxxxxx',
        'user' => 'xxxxxxxxxxxx',
        'password' => 'xxxxxxxxxxxx'
    ),
);

Nothing special here but worth noting the live, stage and local environment variables which we just set in .env.php. Usually these are domains or IP addresses but local, stage and live are much easier to work with and you don’t run into the issues you sometimes get when comparing your config keys with similar $_SERVER['SERVER_NAME'] in typical multi-environment configs.

I’m hard-coding the DB credentials in here rather than keep them in .env.php per nystudio107’s example because I’ve got multiple .env.php’s floating around and I’d just rather have that info in one place (craft/app/config/db.php).

general.php

<?php
return array(

    // All environments
    '*' => array(
        'allowedFileExtensions' => 'jpeg, jpg, pdf, png, gif, svg',
        'allowUppercaseInSlug' => false,
        'assetVersion' => '1.0', // filename revving for cache busting (production only)
        'autoLoginAfterAccountActivation' => true,
        'cpTrigger' => 'admin',
        'craftEnv' => getenv('CRAFTENV_CRAFT_ENVIRONMENT'),
        'defaultImageQuality' => 80,
        'errorTemplatePrefix' => '/',
        'featureEnabled' => array(
            'captcha' => false, // Also add all locale URLs to Google
        ),
        'filenameWordSeparator' => '-',
        'loginPath' => 'sign-in',
        'maxUploadFileSize' => 5000000,
        'omitScriptNameInUrls' => true,
        'postLoginRedirect' => '', // An empty string gives you the site homepage
        'purgePendingUsersDuration' => 'P3M', // Set to 3 months
        'registerPath' => 'sign-up',
        'siteName' => getenv('CRAFTENV_SITE_NAME'),
        'siteUrl' => getenv('CRAFTENV_SITE_URL'),
        'timezone' => getenv('CRAFTENV_TIMEZONE'),
        'useEmailAsUsername' => true,
    ),

    // Live (production) environment
    'live' => array(
        'assetUrl' => 'https://assets.medicsaway.com',
        'backupDbOnUpdate' => true,
        'devMode' => false,
        'enableTemplateCaching' => true,
        'environmentVariables' => array(
            'baseUrl'  => 'https://assets.medicsaway.com',
            'basePath' => '/srv/users/serverpilot/apps/medicsawaycom/public',
        ),
        'isSystemOn' => getenv('CRAFTENV_IS_SYSTEM_ON_LIVE') === '1' ? true: false,
    ),

    // Staging (pre-production) environment
    'stage' => array(
        'assetUrl' => 'https://assets.medicsaway.com',
        'backupDbOnUpdate' => true,
        'devMode' => true,
        'enableTemplateCaching' => false,
        'environmentVariables' => array(
            'baseUrl'  => 'https://assets.medicsaway.com',
            'basePath' => '/srv/users/serverpilot/apps/medicsawaycom/public',
        ),
        'isSystemOn' => true,
    ),

    // Local (development) environment
    'local' => array(
        'assetUrl' => 'http://local.medicsaway.dev:8888',
        'backupDbOnUpdate' => false,
        'devMode' => true,
        'enableTemplateCaching' => false,
        'environmentVariables' => array(
            'baseUrl'  => 'http://local.medicsaway.dev',
            'basePath' => '/Users/ianebden/Sites/medicsawaycom/public',
        ),
        'isSystemOn' => true,
    ),

);

So this is mostly regular stuff but we’re also passing in some of our $craftEnvVars from .env.php. These include: craftEnv (live, stage and local), siteName, siteUrl and timezone. Some other items to note are:

assetUrl

I’m sharing static assets (images, scripts, PDFs…) across all locales so I’ve created a subdomain that I use for assets on all locales — https://assets.medicsaway.com for staging and live; and just the default domain http://local.medicsaway.dev for local.

If you’re going to use this method though make sure you allow cross-origin requests so your assets can be shared across domains. There’s much more detail about cross-origin here but you can do this in .htaccess with:

<IfModule mod_headers.c>
    Header set Access-Control-Allow-Origin "*"
</IfModule>

<IfModule mod_setenvif.c>
    <IfModule mod_headers.c>
        <FilesMatch "\.(bmp|cur|gif|ico|jpe?g|png|svgz?|webp)$">
            SetEnvIf Origin ":" IS_CORS
            Header set Access-Control-Allow-Origin "*" env=IS_CORS
        </FilesMatch>
    </IfModule>
</IfModule>

Of course I could just use a CDN like Amazon S3 instead, and I’ll probably move to one at some point after launch.

isSystemOn

'isSystemOn' => getenv('CRAFTENV_IS_SYSTEM_ON_LIVE') === '1' ? true: false,

Remember the IS_SYSTEM_ON_LIVE we set earlier in .env.php $craftEnvVars? Well here it is passed into my general config. Now for reasons I don't fully understand Craft passes my IS_SYSTEM_ON_LIVE string ('true' or 'false') into general.php as '1' or null, so I’ve added a ternary operator to convert this variable back into true or false. Developers much smarter than me might know a neater way of handling this, but my way works just fine.

So from now on, if/when I ever need to add a new locale all I need to do is:

  1. Buy my domain
  2. Create a new app in ServerPilot, and add my domain (inc. stage subdomain(s))
  3. Follow the steps in Craft's Localization Guide
  4. Add a .env.php to the root of my new App directory and make the necessary changes to my new app’s /public/index.php
  5. Oh, and obviously translate all the content!

Template Tweaks

Lastly here are a couple of little template tweaks you should have on your localized sites. The obvious one is telling browsers which language our pages are using in the html lang attribute.

<html lang="{{ craft.locale|replace('_','-') }}">

Note the |replace filter to replace Craft’s locale underscores with the preferred dash of ISO language codes e.g. en-gb instead of en_gb.

Also don’t forget to add alternate language links to your pages. It’s often neglected on localized sites but it can be a useful tool in your SEO toolbox. Use hreflang for language and regional URLs; either in your XML sitemap or in the head of your pages. I chose the latter and output locale alternatives for my pages with:

{% if entry is defined %}
	{% for locale in craft.i18n.getSiteLocales() %}
	<link rel="alternate" href="{{ craft.entries.id(entry.id).locale(locale.id).first().url }}" hreflang="{{ locale|replace('_','-') }}">
	{% endfor %}
{% endif %}

Summary

So there you have it. Like I say, it’s not a definitive guide. It&rsuo;s just my approach this time around, and will no doubt refine over time. If you’re embarking on your first localized multi-environment Craft site then hopefully this has given you a steer. If you have some feedback or suggestions though I would love to hear them. Grab me on Craft Slack or Twitter.

Bye, adios, au revoir, auf wiedersehen…

Let us help tell your story.

Ready to inject some karma into your project?

Get started