How I Built 200+ Calculators Serving 30 Languages Without a Database
When I started building OnlineCalcAI, I faced a seemingly impossible challenge: creating 206+ unique calculators in 30 languages (6000+ pages total) while maintaining Lighthouse scores above 94/100. The conventional approach would be a database-driven platform with complex queries, caching layers, and infrastructure overhead.
Instead, I chose a radically different path. No databases. No servers polling data. Just PHP 8.1, JSON configuration files, and a clever file-based caching strategy. Here's how it works.
The Problem with Traditional Approaches
Database-driven calculator platforms struggle with scale:
- Query overhead: Each page load triggers SQL queries for content, metadata, translations
- Cache invalidation: Keeping thousands of pages fresh after updates becomes a nightmare
- Infrastructure costs: Databases require maintenance, backups, replication
- Developer experience: Debugging multi-language content across tables is tedious
I wanted something simpler. Something that scaled predictably.
Architecture Overview
The solution uses three core concepts:
User Request
↓
Router (request.php) → matches language + calculator
↓
Load locales (locales/{lang}.json) → UI strings in 30 languages
↓
Load calculator (calculators/{name}.json) → fields, logic, formulas
↓
Render template (templates/calculator.php) → HTML + JavaScript
↓
Cache to file (cache/calculators/{lang}-{name}.html)
↓
Serve static HTML on repeat requests
The Routing Layer
<?php
// request.php - Zero-framework routing
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$lang = 'en'; // default
$calculator = 'basic-calculator';
// Extract language from URL: /fr/calculator-name/
$parts = array_filter(explode('/', trim($uri, '/')));
if (count($parts) >= 2 && strlen($parts[0]) === 2) {
$lang = $parts[0];
$calculator = implode('-', array_slice($parts, 1));
}
// Validate language exists
$validLanguages = array_map(
fn($f) => basename($f, '.json'),
glob('locales/*.json')
);
if (!in_array($lang, $validLanguages)) {
$lang = 'en';
}
// Load or generate cache
$cacheFile = "cache/calculators/{$lang}-{$calculator}.html";
if (file_exists($cacheFile) && filemtime($cacheFile) > time() - 86400) {
// Cache hit - serve static HTML
readfile($cacheFile);
exit;
}
// Cache miss - render and store
include 'templates/calculator.php';
The beauty here: for 95% of requests, we're just serving a static HTML file from disk. No PHP execution beyond the file_exists() check.
Localization Strategy
Instead of database tables with 30 language columns, I use modular JSON files:
// locales/en.json
{
"age_calculator": {
"title": "Age Calculator",
"description": "Calculate your exact age in years, months, and days",
"fields": {
"birthDate": "Birth Date",
"resultAge": "Your Age"
},
"button": "Calculate"
},
"bmi_calculator": {
"title": "BMI Calculator",
...
}
}
// locales/fr.json
{
"age_calculator": {
"title": "Calculatrice d'Âge",
"description": "Calculez votre âge exact en années, mois et jours",
...
}
}
In PHP, loading is trivial:
<?php
function getLocale($lang) {
static $cache = [];
if (isset($cache[$lang])) {
return $cache[$lang];
}
$file = "locales/{$lang}.json";
if (!file_exists($file)) {
$file = 'locales/en.json';
}
$cache[$lang] = json_decode(file_get_contents($file), true);
return $cache[$lang];
}
$strings = getLocale('fr');
echo $strings['age_calculator']['title'];
No database hits. No SELECT queries. Just file I/O (which is cached by the OS anyway).
Calculator Configuration
Each calculator lives in a JSON file defining its structure:
// calculators/bmi-calculator.json
{
"slug": "bmi-calculator",
"type": "calculator",
"fields": [
{
"id": "height",
"type": "number",
"label_key": "fields.height",
"placeholder": "170",
"unit": "cm",
"min": 50,
"max": 300
},
{
"id": "weight",
"type": "number",
"label_key": "fields.weight",
"placeholder": "70",
"unit": "kg",
"min": 20,
"max": 500
}
],
"formula": "weight / ((height / 100) ** 2)",
"resultLabel": "bmi_result",
"resultUnit": "kg/m²",
"interpretation": {
"underweight": {"min": 0, "max": 18.5},
"normal": {"min": 18.5, "max": 25},
"overweight": {"min": 25, "max": 30},
"obese": {"min": 30, "max": 99}
}
}
The template reads this JSON and generates both HTML and JavaScript:
<?php
// templates/calculator.php
$calculator = json_decode(file_get_contents("calculators/{$calculator}.json"), true);
$i18n = getLocale($lang);
$calc_strings = $i18n[str_replace('-', '_', $calculator['slug'])] ?? [];
?>
<div class="calculator">
<h1><?php echo htmlspecialchars($calc_strings['title'] ?? 'Calculator') ?></h1>
<p><?php echo htmlspecialchars($calc_strings['description'] ?? '') ?></p>
<form id="calcForm">
<?php foreach ($calculator['fields'] as $field): ?>
<div class="form-group">
<label for="<?php echo $field['id'] ?>">
<?php
$label = $calc_strings['fields'][$field['id']] ?? $field['label_key'];
echo htmlspecialchars($label);
?>
</label>
<input
type="<?php echo $field['type'] ?>"
id="<?php echo $field['id'] ?>"
name="<?php echo $field['id'] ?>"
placeholder="<?php echo $field['placeholder'] ?>"
min="<?php echo $field['min'] ?>"
max="<?php echo $field['max'] ?>"
required
/>
<?php if ($field['unit']): ?>
<span class="unit"><?php echo $field['unit'] ?></span>
<?php endif; ?>
</div>
<?php endforeach; ?>
<button type="submit"><?php echo $calc_strings['button'] ?? 'Calculate' ?></button>
</form>
<div id="result" class="hidden"></div>
</div>
<script>
const calculatorConfig = <?php echo json_encode($calculator) ?>;
const formula = "<?php echo addslashes($calculator['formula']) ?>";
document.getElementById('calcForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const values = Object.fromEntries(formData);
try {
// Evaluate formula safely - in production, use a math parser library
const result = Function('"use strict"; return (' + formula + ')')
.call(values);
document.getElementById('result').innerHTML = `
<p><strong>${calculateConfig.resultLabel}:</strong> ${result.toFixed(2)} ${calculatorConfig.resultUnit}</p>
`;
document.getElementById('result').classList.remove('hidden');
} catch (error) {
console.error('Calculation error:', error);
}
});
</script>
Cache Strategy
The file-based cache is aggressive but sensible:
<?php
function cacheCalculator($lang, $calculator, $html) {
$cacheDir = "cache/calculators";
// Create directory if missing
if (!is_dir($cacheDir)) {
mkdir($cacheDir, 0755, true);
}
$cacheFile = "{$cacheDir}/{$lang}-{$calculator}.html";
// Atomic write: write to temp file, then rename
$temp = "{$cacheFile}.tmp";
file_put_contents($temp, $html);
rename($temp, $cacheFile);
// Set cache validity to 24 hours
touch($cacheFile, time());
}
// Clear entire cache when needed
function clearCache() {
array_map('unlink', glob('cache/calculators/*.html'));
}
When you deploy a new calculator or update translations, you simply call clearCache() once, and all 6000+ pages regenerate on first access.
Performance Results
This approach achieves remarkable metrics:
| Metric | Score |
|---|---|
| Lighthouse Performance | 94/100 |
| Lighthouse Accessibility | 100/100 |
| Lighthouse Best Practices | 100/100 |
| Lighthouse SEO | 100/100 |
| Core Web Vitals | All Green |
| Response Time | < 100ms |
No database queries. No JavaScript frameworks. Pure PHP + vanilla JS.
Scaling Lessons
What surprised me:
- File I/O is fast enough - Operating systems cache file reads aggressively. You don't need a database until you hit 100K+ requests/second
- JSON parsing is negligible - php_json is compiled C code. Parsing a 50KB locale file takes < 1ms
- Disk space is cheap - 6000 HTML files = ~500MB. That's nothing on modern servers
- Maintenance is simpler - No migrations, no schema changes, no query optimization
The tradeoff: this approach doesn't work well for applications with dynamic content (like Twitter feeds) or complex search. But for content-driven projects? It's unbeatable.
Next Steps
If you're building a similar project, start here:
- Template everything - Make PHP templates that read JSON configs
- Cache aggressively - Serve static HTML for 99% of requests
- Validate your assumptions - I almost over-engineered this with Redis and PostgreSQL before testing pure files
- Monitor file system - Watch disk I/O, not database queries
The full source code for OnlineCalcAI is available at https://onlinecalcai.com - feel free to use the calculators and see the architecture in action.
Have you built multi-language platforms? What approach did you take? Share in the comments.
Build fast. Stay simple. Let files do what they do best.
Top comments (0)