DEV Community

SIKOUTRIS
SIKOUTRIS

Posted on

How I Built a 700+ Calculator Platform with Pure PHP, Zero Frameworks, and a JSON Registry Pattern

A few months ago I had a dumb idea. I wanted to build a calculator website. Not one calculator — hundreds of them. Dog age converters, tip splitters, dew point estimators, projectile motion solvers. The kind of tools people Google once, use for ten seconds, and never bookmark.

Turns out, building one calculator is trivial. Building 700 of them forces you to rethink everything about how you structure a web application. Here's what I learned scaling OnlineCalcAI from a weekend prototype to a multi-language platform serving pages in under 100ms — without touching a single framework.

Why pure PHP?

I know. PHP in 2026. Hear me out.

Calculator pages are fundamentally static-ish. The server renders an HTML page with some form fields and a chunk of JavaScript that does the actual math. There's no user authentication, no database queries, no session state. The entire "backend" is: read a JSON file, inject it into a template, return HTML.

For that workload, a framework is overhead you pay on every request for features you never use. Laravel's service container, Symfony's event dispatcher, Next.js's hydration — none of that helps when your hot path is json_decode() followed by include 'template.php'.

The stack ended up being:

PHP 8.1 (no framework, no database)
├── calculators/
│   ├── _registry.json         # Central index of all calculators
│   └── {category}/
│       └── {calc_id}.json     # Definition: fields, variants, related
├── locales/
│   └── {lang}.json            # Translations (30 languages planned)
├── templates/
│   ├── layout.php             # Shell HTML
│   └── calculator.php         # Calculator page template
├── includes/
│   └── seo.php                # Meta tags, Schema.org, breadcrumbs
├── cache/
│   └── {lang}/{type}/         # Pre-rendered HTML fragments
└── assets/
    ├── css/
    └── js/
Enter fullscreen mode Exit fullscreen mode

Total dependencies: zero. composer.json does not exist. The deployment story is "upload files via FTP" and I'm not even embarrassed about it.

The registry pattern

This is the core architectural decision and probably the most reusable idea in the whole project. Every calculator is described by two files:

1. A JSON definition (calculators/pets/dog-age-calculator.json):

{
  "id": "dog-age-calculator",
  "category": "pets",
  "fields": [
    { "id": "dog_age", "type": "number", "required": true, "step": "0.1" },
    { "id": "dog_size", "type": "select", "required": true },
    { "id": "human_age", "type": "output" },
    { "id": "life_stage", "type": "output" }
  ],
  "variants": [
    { "id": "simple", "fields": ["dog_age", "human_age"] },
    { "id": "detailed", "fields": ["dog_age", "dog_size", "human_age", "life_stage"] }
  ],
  "related": ["cat-age-calculator", "puppy-weight-calculator"]
}
Enter fullscreen mode Exit fullscreen mode

2. A JavaScript logic function (pure function, no DOM dependency):

function(variant, inputs) {
  const age = parseFloat(inputs.dog_age);
  const size = inputs.dog_size || 'medium';
  const multipliers = { small: 4.32, medium: 5.5, large: 7.46 };
  const humanAge = age <= 2
    ? 10.5 * age
    : 21 + (age - 2) * multipliers[size];
  const stage = humanAge < 15 ? 'Puppy' :
                humanAge < 30 ? 'Young Adult' :
                humanAge < 55 ? 'Adult' : 'Senior';
  return { human_age: Math.round(humanAge), life_stage: stage };
}
Enter fullscreen mode Exit fullscreen mode

The PHP template reads the definition, renders the form fields, injects the logic, and the browser handles the calculation. The server never computes anything — it just assembles HTML.

The central registry (_registry.json) is an array of every calculator ID, category, and keyword. It drives the sitemap, the category pages, the "All Calculators" listing, and the related-calculators sidebar. One file, four features.

// Loading the registry is dead simple
$registry = json_decode(
    file_get_contents(CALCULATORS_PATH . '/_registry.json'),
    true
);

// Filter by category
$pets = array_filter($registry, fn($c) => $c['category'] === 'pets');

// Generate sitemap entries
foreach ($registry as $calc) {
    $slug = $calc['slug'] ?? $calc['id'] . '-calculator';
    echo "<url><loc>https://onlinecalcai.com/en/{$slug}</loc></url>\n";
}
Enter fullscreen mode Exit fullscreen mode

Why not a database? Because 700 JSON files totaling ~4MB load faster from the filesystem than a round-trip to even a local MySQL instance, especially with OPcache warming the PHP side. The registry file itself is 83KB — json_decode() processes it in about 2ms.

Batch generation: manufacturing calculators at scale

You can't hand-write 700 calculator definitions. Well, you can. I did for the first 50. Then I built a batch generation pipeline.

The pipeline works in three stages:

Stage 1: Master list. A JSON file listing every calculator I want to build, with the target keyword and estimated search volume:

[
  { "calc_id": "love-calculator", "category": "party-planning",
    "keyword": "love calculator", "est_volume": 550000 },
  { "calc_id": "dog-age-calculator", "category": "pets",
    "keyword": "dog age calculator", "est_volume": 49500 },
  { "calc_id": "wind-chill-calculator", "category": "weather",
    "keyword": "wind chill calculator", "est_volume": 22200 }
]
Enter fullscreen mode Exit fullscreen mode

Stage 2: Generator script. A Python script (generate-batch.py) that takes master list items and produces complete calculator packages — JSON definition, JavaScript logic, English locale with meta tags, FAQ content, and educational sections. Each calculator gets ~800 words of content.

python3 generate-batch.py onlinecalcai --all --size 25
# Output:
#   Wrote batch-001.json (25 calcs)
#   Wrote batch-002.json (25 calcs)
#   ...
#   Done! Generated 500 calcs in 20 batches
Enter fullscreen mode Exit fullscreen mode

The generator has a knowledge base for calculators where the formula matters — physics formulas need to be correct, financial calculations need proper amortization schedules. For simpler calculators (unit converters, date calculators), it can produce valid output from templates. Either way, every calculator gets:

  • Working JavaScript with real formulas
  • 5 FAQ entries (for Schema.org FAQPage markup)
  • 3 content sections of 150+ words each
  • Related calculator links for internal linking
  • Properly formatted field labels and meta descriptions

Stage 3: Enrichment. A separate script (enrich-formulas.py) scans deployed calculators for generic placeholder logic and replaces it with accurate, specific formulas. It has a database of 200+ conversion factors and formula definitions. This catches anything the generator missed or simplified.

The workflow is messy and manual in places. I run the generator, spot-check a few outputs, fix issues, and iterate. But the alternative — writing 700 calculator pages by hand — would have taken months instead of days.

Performance: sub-100ms responses from shared hosting

The site runs on shared hosting. Not a VPS, not a container — a cPanel account on a shared PHP server. Here's how it still serves pages fast.

File-based caching

Every calculator page generates identical HTML for a given language. So the first request renders the page and writes the output to a cache file. Subsequent requests serve the cached file directly:

$cacheKey = "{$lang}/{$category}/{$calcId}";
$cachePath = CACHE_PATH . "/{$cacheKey}.html";

if (CACHE_ENABLED && file_exists($cachePath)
    && (time() - filemtime($cachePath)) < CACHE_TTL) {
    readfile($cachePath);
    exit;
}

// Render the page...
ob_start();
include TEMPLATES_PATH . '/calculator.php';
$html = ob_get_clean();

// Write to cache
$dir = dirname($cachePath);
if (!is_dir($dir)) mkdir($dir, 0755, true);
file_put_contents($cachePath, $html);
echo $html;
Enter fullscreen mode Exit fullscreen mode

Cache files are organized in subdirectories (cache/{lang}/{type}/) to avoid flat-directory performance issues with thousands of files. The TTL is 24 hours, which is fine since calculator content rarely changes.

OPcache configuration

PHP's OPcache is the single biggest performance lever on shared hosting. The key settings:

opcache.validate_timestamps=1
opcache.revalidate_freq=0
Enter fullscreen mode Exit fullscreen mode

With validate_timestamps=1, OPcache checks if files have changed but respects the revalidation frequency. Setting revalidate_freq=0 means it checks on every request — a slight overhead, but it means deployments take effect immediately without needing to restart PHP-FPM (which you can't do on shared hosting anyway).

For sites where I deploy less frequently, I set validate_timestamps=0 and use a one-shot cron job to clear OPcache after FTP uploads. That squeezes out another few milliseconds per request.

Lazy loading and minimal assets

Each calculator page loads:

  • One CSS file (~12KB minified)
  • One shared JS file (~4KB)
  • One calculator-specific JS logic function (~1-3KB, inline)
  • Images: lazy-loaded category hero images from Unsplash via loading="lazy"

No jQuery. No React. No build step. The JavaScript that powers each calculator is a single pure function — it reads form inputs, runs the formula, and writes results to output fields. DOM interaction is about 30 lines of vanilla JS.

SEO architecture: making 700 pages discoverable

Building pages is half the battle. Getting Google to find and rank them is the other half.

Dynamic sitemap

The sitemap is generated from the registry at request time (and cached, naturally):

// sitemap.php
header('Content-Type: application/xml');
$registry = json_decode(file_get_contents(CALCULATORS_PATH . '/_registry.json'), true);
$langs = getActiveLanguages();

echo '<?xml version="1.0" encoding="UTF-8"?>';
echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
             xmlns:xhtml="http://www.w3.org/1999/xhtml">';

foreach ($langs as $lang) {
    foreach ($registry as $calc) {
        $slug = getLocalizedSlug($calc['id'], $lang);
        echo "<url>";
        echo "<loc>https://onlinecalcai.com/{$lang}/{$slug}</loc>";
        foreach ($langs as $altLang) {
            $altSlug = getLocalizedSlug($calc['id'], $altLang);
            echo "<xhtml:link rel='alternate' hreflang='{$altLang}'
                   href='https://onlinecalcai.com/{$altLang}/{$altSlug}'/>";
        }
        echo "</url>";
    }
}
echo '</urlset>';
Enter fullscreen mode Exit fullscreen mode

When I add a new calculator, it automatically appears in the sitemap. No manual sitemap management.

Schema.org markup

Every calculator page emits three types of structured data:

{
  "@context": "https://schema.org",
  "@type": "WebApplication",
  "name": "Dog Age Calculator",
  "applicationCategory": "UtilityApplication",
  "operatingSystem": "All"
}
Enter fullscreen mode Exit fullscreen mode

Plus FAQPage schema from the 5 FAQ entries, and BreadcrumbList for navigation context. The FAQ schema is particularly effective — it can earn those expandable FAQ results in Google SERPs.

Multi-language architecture

This was the trickiest part. The URL structure is /{lang}/{calculator-slug}, with language activation controlled by a schedule in the config:

define('LANGUAGE_SCHEDULE', [
    'en' => '2026-02-07',
    'fr' => '2026-03-01',
    'es' => '2026-03-01',
    'de' => '2026-03-15',
    // ... 30 languages total
]);

function getActiveLanguages(): array {
    $today = date('Y-m-d');
    $active = [];
    foreach (LANGUAGE_SCHEDULE as $lang => $date) {
        if ($today >= $date
            && file_exists(LOCALES_PATH . '/' . $lang . '.json')) {
            $active[] = $lang;
        }
    }
    return $active;
}
Enter fullscreen mode Exit fullscreen mode

Languages activate automatically on their scheduled date, but only if the locale file exists. This prevents accidentally serving untranslated content — a mistake I almost made when I discovered my initial French and Spanish locale files contained zero actual translations (the text was still English). That would have been a SEO disaster: Google penalizes pages that claim to be in French but serve English content.

The translation pipeline itself uses Google Translate API with a batch separator approach. Instead of one API call per string (which would mean 50,000+ calls for 700 calculators times ~70 translatable strings each), I concatenate strings with a [SEP] delimiter and translate in chunks of 20-40 at a time. That brought throughput from 3 translations per second to about 50.

Canonical URLs and hreflang

Every page sets a canonical URL and hreflang alternates. This is non-negotiable with multi-language sites — without it, Google sees 30 near-duplicate pages and doesn't know which to index:

<link rel="canonical" href="https://onlinecalcai.com/en/dog-age-calculator" />
<link rel="alternate" hreflang="en"
      href="https://onlinecalcai.com/en/dog-age-calculator" />
<link rel="alternate" hreflang="fr"
      href="https://onlinecalcai.com/fr/calculateur-age-chien" />
<link rel="alternate" hreflang="x-default"
      href="https://onlinecalcai.com/en/dog-age-calculator" />
Enter fullscreen mode Exit fullscreen mode

Lessons learned the hard way

The registry is sacred. My _registry.json got corrupted once during a batch deployment — an accidental overwrite replaced 705 entries with partial metadata. The site kept running (PHP doesn't crash on missing JSON fields, it just returns null), but the sitemap broke, category pages showed empty, and internal linking stopped working. I now keep a backup copy in a separate directory and validate the registry after every deployment.

Slug collisions are sneaky. My generator appended -calculator to every calc ID to create URL slugs. Some calculators already had "calculator" in their ID. Result: age-calculator-calculator, macro-calculator-calculator. I caught five of these in production and had to set up 301 redirects. Now the generator checks for the suffix before appending.

Content quality at scale is a real problem. When you generate 500 FAQ sections from templates, they all sound the same. "This calculator uses standard formulas..." appearing on every page is a quality signal Google can pick up. I'm still working through this — enriching content page by page for the highest-traffic calculators first, and letting the long-tail pages sit with generic content until they earn impressions.

Shared hosting has one great advantage. You can't over-engineer. There's no Kubernetes to configure, no Docker images to optimize, no CI/CD pipeline to maintain. When deployment is "upload via FTP," you focus on what matters: the content and the code. The constraint forced me to keep the architecture simple, and simple is fast.

The numbers

After a few weeks live:

  • 705 calculators in the registry (211 with full English translations)
  • 30 categories spanning pets, cooking, travel, weather, physics, and more
  • Page load time: ~80-120ms for cached pages on shared hosting
  • Lighthouse scores: 94 performance / 100 accessibility / 100 best practices / 100 SEO
  • Total codebase size: ~15 PHP files, ~4MB of calculator JSON
  • Monthly hosting cost: ~$5 (shared hosting, shared with other projects)

What I'd do differently

If I started over, I'd probably keep 90% of the architecture identical. The JSON registry pattern, the file-based caching, the pure-function JavaScript logic — all of that scales well and stays simple.

The one thing I'd change: I'd build a CLI validation tool from day one. Something that reads every JSON definition, parses every JS logic function, checks every locale file for missing translations, and flags any broken internal links. I built pieces of this after hitting problems in production. Having it upfront would have saved me from the registry corruption, the slug collisions, and the untranslated locale files.

If you're building something similar — a content-heavy site with hundreds or thousands of similar pages — the registry pattern is worth stealing. One source of truth, programmatic generation, and a simple template layer. No framework required.


What's your experience with programmatic content generation? Have you dealt with multi-language SEO at scale? I'd be curious to hear how others approach these problems. Drop a comment below.

Top comments (0)