Multi-language system (how to, step-by-step)

Hello everyone!

I fell in love with Sitely — it reminds me of the old, secure way of building websites… but without the need for coding (aside from language-specific logic). The only drawback is that the Preview function doesn’t support PHP execution. To see PHP in action, you’ll need to publish your site on a PHP-enabled server, and you’ll also need a Sitely Developer License.

P.S.: I’m a new user, so I can’t upload screenshots yet.

Here’s a quick tutorial on how to create a multi-language website without duplicating all the pages or redoing the formatting for different screen sizes:

1. make sure all pages you create use .php extension
2. create on your server those files :

public_html/
├── ln/
│   ├── languages/
│   │   ├── en.json
│   │   ├── fr-ca.json etc..
│   ├── assets/
│   │   ├── dropdown-arrow.svg (you can create or upload any svg arrow)
│   ├── set_language.php
│   ├── language_handler.php (test page independent of Sitely)
│   ├── language_init.php

3. the language json files look like this :

{
  "EssayezLe": "Try it",
  "buttons": {
  "submit": "Submit",
  "cancel": "Cancel"
  },
  "errors": {
   "required_field": "This field is required.",
   "invalid_email": "Please enter a valid email address."
  },
  "welcome_message": "Welcome !"
}

In the language folder you can use inner arrays like buttons.submit or buttons.cancel for your placeholder language. We will see later on this tutorial.

4. here the code for set_language.php

<?php
header('Content-Type: application/json');

class Language {
	private $translations = [];
	private $lang;
	private const ALLOWED_LANGUAGES = ['en', 'fr-ca'];
	private const DEFAULT_LANGUAGE = 'fr-ca';
	private const LANGUAGE_DIR = __DIR__ . '/languages/';

	public function __construct(?string $lang = null) {
		$this->lang = $this->validateLanguage($lang ?? self::DEFAULT_LANGUAGE);
		$this->loadTranslations();
	}

	private function validateLanguage(string $lang): string {
		if (in_array($lang, self::ALLOWED_LANGUAGES, true)) {
			return $lang;
		}
		error_log("Invalid language provided: {$lang}. Defaulting to " . self::DEFAULT_LANGUAGE);
		return self::DEFAULT_LANGUAGE;
	}

	private function loadTranslations(): void {
		$file = self::LANGUAGE_DIR . "{$this->lang}.json";
		if (!file_exists($file)) {
			$file = self::LANGUAGE_DIR . self::DEFAULT_LANGUAGE . '.json';
			$this->lang = self::DEFAULT_LANGUAGE;
			error_log("Language file not found for {$this->lang}. Using default: " . self::DEFAULT_LANGUAGE);
		}
		$content = file_get_contents($file);
		$data = json_decode($content, true);
		$this->translations = json_last_error() === JSON_ERROR_NONE ? $data : [];
		if (json_last_error() !== JSON_ERROR_NONE) {
			error_log("Invalid JSON in language file: {$file}");
		}
	}

	public function get(string $key, string $default = ''): string {
		$keys = explode('.', $key);
		$value = $this->translations;
		foreach ($keys as $k) {
			if (is_array($value) && isset($value[$k])) {
				$value = $value[$k];
			} else {
				return $default;
			}
		}
		return is_string($value) ? $value : $default;
	}

	public function getTranslations(): array {
		return $this->translations;
	}

	public function getCurrentLanguage(): string {
		return $this->lang;
	}
}

$response = ['success' => false, 'error' => ''];

if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['lang'])) {
	$lang = is_string($_POST['lang']) ? $_POST['lang'] : null;
	$language = new Language($lang);
	$new_lang = $language->getCurrentLanguage();
	
	// Set cookie with secure settings
	setcookie('lang', $new_lang, [
		'expires' => time() + (30 * 24 * 60 * 60),
		'path' => '/',
		'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
		'httponly' => false,
		'samesite' => 'Strict'
	]);
	
	$response['success'] = true;
	$response['lang'] = $new_lang;
	$response['translations'] = [
		'welcome_message' => $language->get('welcome_message', 'Welcome!'),
		'buttons.submit' => $language->get('buttons.submit', 'Submit'),
		'current_language' => $new_lang
	];
} else {
	$response['error'] = 'Invalid request';
}

echo json_encode($response);
exit;

5. the code for language_init.php

<?php
// Enable error logging
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/error.log');
ini_set('display_errors', 0);

class Language {
    private $translations = [];
    private $lang;
    private const ALLOWED_LANGUAGES = ['en', 'fr-ca'];
    private const DEFAULT_LANGUAGE = 'fr-ca';
    private const LANGUAGE_DIR = __DIR__ . '/languages/';

    public function __construct(?string $lang = null) {
        try {
            $this->lang = $this->validateLanguage($lang ?? $_COOKIE['lang'] ?? self::DEFAULT_LANGUAGE);
            $this->loadTranslations();
        } catch (Exception $e) {
            error_log("Language constructor error: " . $e->getMessage());
            $this->lang = self::DEFAULT_LANGUAGE;
            $this->translations = [];
        }
    }

    private function validateLanguage(string $lang): string {
        if (!in_array($lang, self::ALLOWED_LANGUAGES, true)) {
            error_log("Invalid language: {$lang}. Defaulting to " . self::DEFAULT_LANGUAGE);
            return self::DEFAULT_LANGUAGE;
        }
        return $lang;
    }

    private function loadTranslations(): void {
        $file = self::LANGUAGE_DIR . "{$this->lang}.json";
        if (!file_exists($file)) {
            $file = self::LANGUAGE_DIR . self::DEFAULT_LANGUAGE . '.json';
            $this->lang = self::DEFAULT_LANGUAGE;
            if (!file_exists($file)) {
                error_log("Language file not found: {$file}");
                $this->translations = [];
                return;
            }
        }
        if (!is_readable($file)) {
            error_log("Language file not readable: {$file}");
            $this->translations = [];
            return;
        }
        $content = @file_get_contents($file);
        if ($content === false) {
            error_log("Failed to read language file: {$file}");
            $this->translations = [];
            return;
        }
        $data = json_decode($content, true);
        if (json_last_error() !== JSON_ERROR_NONE) {
            error_log("Invalid JSON in language file: {$file}. Error: " . json_last_error_msg());
            $this->translations = [];
            return;
        }
        $this->translations = $data;
    }

    public function get(string $key, string $default = ''): string {
        $keys = explode('.', $key);
        $value = $this->translations;
        foreach ($keys as $k) {
            if (is_array($value) && isset($value[$k])) {
                $value = $value[$k];
            } else {
                return $default;
            }
        }
        return is_string($value) ? $value : $default;
    }

    public function getCurrentLanguage(): string {
        return $this->lang;
    }
}

// Initialize language globally
global $language;
$language = new Language();
?>

6. and finally the code for the test page language_handler.php

<?php
// Enable error logging
ini_set('log_errors', 1);
ini_set('error_log', __DIR__ . '/error.log');
ini_set('display_errors', 0); // Disable display for production

class Language {
	private $translations = [];
	private $lang;
	private const ALLOWED_LANGUAGES = ['en', 'fr-ca'];
	private const DEFAULT_LANGUAGE = 'fr-ca';
	private const LANGUAGE_DIR = __DIR__ . '/languages/'; // Updated for ln/languages/
	private const LANGUAGE_NAMES = [
		'en' => 'English',
		'fr-ca' => 'Français (CA)'
	];

	public function __construct(?string $lang = null) {
		try {
			$this->lang = $this->validateLanguage($lang ?? $_COOKIE['lang'] ?? self::DEFAULT_LANGUAGE);
			$this->loadTranslations();
		} catch (Exception $e) {
			error_log("Language constructor error: " . $e->getMessage());
			$this->lang = self::DEFAULT_LANGUAGE;
			$this->translations = [];
		}
	}

	private function validateLanguage(string $lang): string {
		if (!in_array($lang, self::ALLOWED_LANGUAGES, true)) {
			error_log("Invalid language: {$lang}. Defaulting to " . self::DEFAULT_LANGUAGE);
			return self::DEFAULT_LANGUAGE;
		}
		return $lang;
	}

	private function loadTranslations(): void {
		$file = self::LANGUAGE_DIR . "{$this->lang}.json";
		if (!file_exists($file)) {
			$file = self::LANGUAGE_DIR . self::DEFAULT_LANGUAGE . '.json';
			$this->lang = self::DEFAULT_LANGUAGE;
			if (!file_exists($file)) {
				error_log("Language file not found: {$file}");
				$this->translations = [];
				return;
			}
		}
		if (!is_readable($file)) {
			error_log("Language file not readable: {$file}");
			$this->translations = [];
			return;
		}
		$content = @file_get_contents($file);
		if ($content === false) {
			error_log("Failed to read language file: {$file}");
			$this->translations = [];
			return;
		}
		$data = json_decode($content, true);
		if (json_last_error() !== JSON_ERROR_NONE) {
			error_log("Invalid JSON in language file: {$file}. Error: " . json_last_error_msg());
			$this->translations = [];
			return;
		}
		$this->translations = $data;
	}

	public function get(string $key, string $default = ''): string {
		$keys = explode('.', $key);
		$value = $this->translations;
		foreach ($keys as $k) {
			if (is_array($value) && isset($value[$k])) {
				$value = $value[$k];
			} else {
				return $default;
			}
		}
		return is_string($value) ? $value : $default;
	}

	public function getCurrentLanguage(): string {
		return $this->lang;
	}

	public function getAvailableLanguageFiles(): array {
		$files = glob(self::LANGUAGE_DIR . '*.json');
		if ($files === false) {
			error_log("Failed to glob language files in: " . self::LANGUAGE_DIR);
			return [];
		}
		$result = [];
		foreach ($files as $file) {
			if (!is_readable($file)) {
				error_log("Language file not readable: {$file}");
				continue;
			}
			$filename = basename($file);
			$lang_code = str_replace('.json', '', $filename);
			$friendly_name = self::LANGUAGE_NAMES[$lang_code] ?? $lang_code;
			$result[] = [
				'filename' => $filename,
				'friendly_name' => $friendly_name,
				'supported' => in_array($lang_code, self::ALLOWED_LANGUAGES, true)
			];
		}
		return $result;
	}

	public function getLanguagesFolderUrl(): string {
		try {
			$protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
			$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
			$dir = str_replace($_SERVER['DOCUMENT_ROOT'] ?? '', '', self::LANGUAGE_DIR);
			$dir = str_replace('\\', '/', $dir); // Normalize for Windows
			return rtrim("{$protocol}://{$host}{$dir}", '/');
		} catch (Exception $e) {
			error_log("Error calculating languages folder URL: " . $e->getMessage());
			return 'Unable to determine folder URL';
		}
	}
}

// Initialize language
$init_error = '';
try {
	$language = new Language();
	$lang = $language->getCurrentLanguage();
	$language_files = $language->getAvailableLanguageFiles();
	$languages_folder_url = $language->getLanguagesFolderUrl();
} catch (Exception $e) {
	error_log("Initialization failed: " . $e->getMessage());
	$init_error = "Failed to initialize language system. Please check server logs.";
	$lang = Language::DEFAULT_LANGUAGE;
	$language = new Language();
	$language_files = [];
	$languages_folder_url = 'Error: Unable to determine folder URL';
}
?>

<!DOCTYPE html>
<html lang="<?php echo htmlspecialchars($lang, ENT_QUOTES, 'UTF-8'); ?>">
<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<title><?php echo htmlspecialchars($language->get('welcome_message', 'Welcome!'), ENT_QUOTES, 'UTF-8'); ?></title>
	<!-- jQuery CDN with local fallback -->
	<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
	<script>
		window.jQuery || document.write('<script src="/ln/assets/jquery-3.7.1.min.js"><\/script>');
	</script>
	<style>
		.language-form {
			margin: 20px;
		}
		.language-select {
			appearance: none;
			-webkit-appearance: none;
			-moz-appearance: none;
			padding: 5px 5px 5px 5px;
			font-size: 16px;
			border: 1px solid #d1d5db;
			border-radius: 6px;
			background-color: #ffffff;
			cursor: pointer;
			background-image: url('/ln/assets/dropdown-arrow.svg');
			background-repeat: no-repeat;
			background-position: right 15px center;
			background-size: 12px;
			min-width: 150px;
			transition: border-color 0.2s;
		}
		.language-select:focus {
			outline: none;
			border-color: #3b82f6;
			box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
		}
		.translation {
			transition: opacity 0.3s ease;
		}
		.error-message {
			color: #dc2626;
			margin: 10px 20px;
			display: none;
		}
		.init-error {
			color: #dc2626;
			margin: 10px 20px;
			font-weight: bold;
		}
		.language-files {
			margin: 20px;
			padding: 10px;
			border: 1px solid #e5e7eb;
			border-radius: 4px;
			max-width: 400px;
		}
		.language-files h3 {
			margin: 0 0 10px 0;
			font-size: 16px;
			color: #1f2937;
		}
		.language-files ul {
			list-style-type: none;
			padding: 0;
			margin: 0;
		}
		.language-files li {
			padding: 5px 0;
			color: #374151;
		}
		.language-files .supported {
			color: #15803d;
			font-weight: bold;
		}
		.language-files .unsupported {
			color: #6b7280;
		}
		.language-files p {
			margin: 10px 0;
			font-size: 14px;
			color: #4b5563;
		}
		.language-files .folder-url {
			word-break: break-all;
			font-family: monospace;
		}
	</style>
</head>
<body>
	<?php if ($init_error): ?>
		<p class="init-error"><?php echo htmlspecialchars($init_error, ENT_QUOTES, 'UTF-8'); ?></p>
	<?php endif; ?>

	<h1 class="translation" data-key="welcome_message"><?php echo htmlspecialchars($language->get('welcome_message', 'Welcome!'), ENT_QUOTES, 'UTF-8'); ?></h1>

	<form class="language-form" id="languageForm">
		<select class="language-select" name="lang" aria-label="Select language" onchange="changeLanguage(this.value)">
			<option value="en" <?php echo $lang === 'en' ? 'selected' : ''; ?>>🇬🇧 English</option>
			<option value="fr-ca" <?php echo $lang === 'fr-ca' ? 'selected' : ''; ?>>🇨🇦 Français (CA)</option>
		</select>
	</form>

	<p>Current language: <span class="translation" data-key="current_language"><?php echo htmlspecialchars($lang, ENT_QUOTES, 'UTF-8'); ?></span></p>
	<p>Sample translation: <span class="translation" data-key="buttons.submit"><?php echo htmlspecialchars($language->get('buttons.submit', 'Submit'), ENT_QUOTES, 'UTF-8'); ?></span></p>
	<p class="error-message" id="errorMessage"></p>

	<!-- <div class="language-files">
		<h3>Detected Language Files</h3>
		<p class="folder-url">Languages folder URL: <?php echo htmlspecialchars($languages_folder_url, ENT_QUOTES, 'UTF-8'); ?></p>
		<?php if (empty($language_files)): ?>
			<p>No language files found in the languages/ directory.</p>
		<?php else: ?>
			<ul>
				<?php foreach ($language_files as $file): ?>
					<li class="<?php echo $file['supported'] ? 'supported' : 'unsupported'; ?>">
						<?php echo htmlspecialchars($file['friendly_name'], ENT_QUOTES, 'UTF-8'); ?>
						(<?php echo htmlspecialchars($file['filename'], ENT_QUOTES, 'UTF-8'); ?>)
						<?php if (!$file['supported']): ?>
							- Unsupported
						<?php endif; ?>
					</li>
				<?php endforeach; ?>
			</ul>
		<?php endif; ?>
	</div> -->

	<script>
		function changeLanguage(lang) {
			const errorMessage = document.getElementById('errorMessage');
			errorMessage.style.display = 'none';

			$.ajax({
				url: '/ln/set_language.php',
				type: 'POST',
				data: { lang: lang },
				dataType: 'json',
				success: function(response) {
					if (response.success) {
						$('.translation').each(function() {
							const key = $(this).data('key');
							$(this).fadeTo(200, 0.5, function() {
								$(this).text(key === 'current_language' ? response.lang : response.translations[key] || key).fadeTo(200, 1);
							});
						});
					} else {
						errorMessage.textContent = 'Failed to change language: ' + (response.error || 'Unknown error');
						errorMessage.style.display = 'block';
					}
				},
				error: function(xhr, status, error) {
					errorMessage.textContent = 'Network error. Please try again.';
					errorMessage.style.display = 'block';
					console.error('AJAX error:', error);
				}
			});
		}
	</script>
</body>
</html>

7. insert this code (CSS) for the popup menu in the site Settings > Developer > Code for HTML (all pages)

<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script>
	window.jQuery || document.write('<script src="/ln/assets/jquery-3.7.1.js"><\/script>');
</script>
<style>
	.language-form { margin: 0px; }
	.language-select {
		appearance: none;
		color: white;
		-webkit-appearance: none;
		-moz-appearance: none;
		padding: 5px 5px 5px 5px;
		margin: 0;
		font-size: 28px;
		border: none;
		background-color: transparent;
		cursor: pointer;
		background-image: url('/ln/assets/dropdown-arrow.svg');
		background-repeat: no-repeat;
		background-position: right 1px center;
		background-size: 15px;
		min-width: 56px;
		transition: border-color 0.2s;
	}
	.language-select:focus {
		outline: none;
		border-color: #3b82f6;
		box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
	}
	.error-message {
		color: #dc2626;
		margin: 10px;
		display: none;
	}
	
	/* Styles for larger screens (above 768px) */
	@media (max-width: 769px) {
		.language-select {
			padding: 5px;
			font-size: 12px; /* Larger font for desktop */
			background-size: 10px; /* Larger arrow for desktop */
			min-width: 30px; /* Larger width for desktop */
		}
	
		.language-select:focus {
			box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); /* Stronger shadow for desktop */
		}
	
		.error-message {
			margin: 0px;
		}
	}
	
	
</style>

8. Next, insert this code into each page php insertion

<?php
echo __DIR__;
include 'ln/language_init.php';
?>

-- [Screenshot 2025-05-23 at 21.37.57|523x387](upload://x2DI1KpZsDqlAJuAFMHbrzJvBFh.png)

9. Add an Embedded Content element where you want your menu

-- [Screenshot 2025-05-23 at 21.40.28|149x75](upload://wmXlMajKWiI3BifXVwuBJbx7yfK.png)
And this code :

<div id="languagePopup"></div>
<script>
// Robust function to get cookie value
function getCookie(name) {
	const cookies = document.cookie.split(';');
	for (let cookie of cookies) {
		cookie = cookie.trim();
		if (cookie.startsWith(name + '=')) {
			const value = cookie.substring(name.length + 1);
			console.log(`Found cookie: ${name}=${value}`);
			return value;
		}
	}
	console.log(`Cookie not found: ${name}`);
	return null;
}

// Initialize language dropdown
document.addEventListener('DOMContentLoaded', function() {
	const container = document.getElementById('languagePopup');
	if (!container) {
		console.error('Language popup container not found');
		return;
	}

	// Get current language from cookie or default to 'fr-ca'
	const currentLang = getCookie('lang') || 'fr-ca';
	console.log(`Current language: ${currentLang}`);

	// Create form and select elements
	const form = document.createElement('form');
	form.className = 'language-form';
	form.id = 'languageForm';

	const select = document.createElement('select');
	select.className = 'language-select';
	select.name = 'lang';
	select.setAttribute('aria-label', 'Select language');

	// Define language options
	const options = [
		{ value: 'en', text: '🇬🇧 en' },
		{ value: 'fr-ca', text: '🇨🇦 fr' }
	];

	// Add options to select
	options.forEach(option => {
		const opt = document.createElement('option');
		opt.value = option.value;
		opt.textContent = option.text;
		if (option.value === currentLang) {
			opt.selected = true;
		}
		select.appendChild(opt);
	});

	// Add onchange event
	select.onchange = function() {
		changeLanguage(this.value);
	};

	// Create error message paragraph
	const errorMessage = document.createElement('p');
	errorMessage.className = 'error-message';
	errorMessage.id = 'errorMessage';
	errorMessage.style.display = 'none';

	// Append elements to form and container
	form.appendChild(select);
	container.appendChild(form);
	container.appendChild(errorMessage);
});

// Function to change language via AJAX
function changeLanguage(lang) {
	console.log(`Changing language to: ${lang}`);
	const errorMessage = document.getElementById('errorMessage');
	errorMessage.style.display = 'none';

	$.ajax({
		url: '/ln/set_language.php',
		type: 'POST',
		data: { lang: lang },
		dataType: 'json',
		success: function(response) {
			console.log('AJAX response:', response);
			if (response.success) {
				window.location.reload(); // Reload to apply translations
			} else {
				errorMessage.textContent = 'Failed to change language: ' + (response.error || 'Unknown error');
				errorMessage.style.display = 'block';
			}
		},
		error: function(xhr, status, error) {
			console.error('AJAX error:', status, error);
			errorMessage.textContent = 'Network error. Please try again.';
			errorMessage.style.display = 'block';
		}
	});
}
</script>

10. and finaly your code snippet of what you want to translate. I recommande you to create the whole website in one language, ajuste everything and place your snippet at the end because in preview mode we can’t see the translation :frowning:

Code snippet can be insert into text or buttons caption using Insert SmartField…
Name your snippet (only usefull for easy finding) and reuse this name into you php code

<?php echo $language->get('EssayezLe'); ?>

Screenshot 2025-05-23 at 21.49.42|637x500

it should look like this : (you need to change the text color in the site Settings > Developer > Code for HTML (all pages) CSS code )

Screenshot 2025-05-23 at 21.54.09|190x120

Debugging Steps

If the lang cookie isn’t retrieved:

Console Logs:
    Open F12 > Console and check:
        Found cookie: lang=en or Cookie not found: lang.
        Current language: <value>.
        On switch: Changing language to: <value>, AJAX response: {...}.
    If Cookie not found, the cookie isn’t set or inaccessible.
Cookies:
    Open F12 > Application > Cookies > https://kanjo.app.
    Check for lang cookie:
        Value: en or fr-ca.
        Path: /.
        HttpOnly: false.
        Secure: Set for HTTPS.
        SameSite: Strict.
    If missing, test set_language.php:
    bash

curl -X POST -d "lang=en" https://kanjo.app/ln/set_language.php
    Should return {"success":true,"lang":"en",...}.

Test Cookie:

Create /var/www/html/ln/test_cookie.php:
php

<?php
setcookie('lang', 'en', [
    'expires' => time() + (30 * 24 * 60 * 60),
    'path' => '/',
    'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
    'httponly' => **false**,
    'samesite' => 'Strict'
]);
echo "Cookie set. Check browser cookies.";
?>
Access https://kanjo.app/ln/test_cookie.php and verify cookie.

Translations:

If <?php echo $language->get('TranslationXYZ'); ?> fails, ensure language_init.php is included.
Test with /var/www/html/ln/test.php:
php

<?php
include 'language_init.php';
global $language;
echo $language->get('TranslationXYZ', 'Default text');
?>

Error Log:

Check /var/www/html/ln/error.log:
bash

cat /var/www/html/ln/error.log

File Paths:

Verify:
bash

    ls -l /var/www/html/ln/languages/
    ls -l /var/www/html/ln/assets/

Expected Output

Console Logs:
    Found cookie: lang=en or Cookie not found: lang.
    Current language: en or fr-ca.
    On switch: Changing language to: en, AJAX response: {success: true, ...}.
Dropdown: Reflects lang cookie (en or fr-ca).
Cookie: lang=en or fr-ca in F12 > Application > Cookies.
Translations:
    lang=en: <p>Hello, World!</p>
    lang=fr-ca: <p>Bonjour le monde !</p>