DEV Community

SIKOUTRIS
SIKOUTRIS

Posted on • Originally published at onlinecalcai.com

How I Built 200+ Calculators Serving 30 Languages Without a Database

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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",
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode
// locales/fr.json
{
  "age_calculator": {
    "title": "Calculatrice d'Âge",
    "description": "Calculez votre âge exact en années, mois et jours",
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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'];
Enter fullscreen mode Exit fullscreen mode

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}
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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'));
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. File I/O is fast enough - Operating systems cache file reads aggressively. You don't need a database until you hit 100K+ requests/second
  2. JSON parsing is negligible - php_json is compiled C code. Parsing a 50KB locale file takes < 1ms
  3. Disk space is cheap - 6000 HTML files = ~500MB. That's nothing on modern servers
  4. 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:

  1. Template everything - Make PHP templates that read JSON configs
  2. Cache aggressively - Serve static HTML for 99% of requests
  3. Validate your assumptions - I almost over-engineered this with Redis and PostgreSQL before testing pure files
  4. 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)