Docs · i18n & auto-translation

i18n & auto-translation

FortPass automatically translates every piece of editable content living in the database (plans, features, testimonials, blog posts and certain entries from the settings table) into every language installed under /lang/*.php. The process is silent, incremental and dependency-free (no Composer).

Overview

  Admin saves content      translate_now()           is short + OK?
  in source language ─────────────────────►   yes ──┐         no (long, 429, etc)
                                                    │                │
                                                    ▼                ▼
                                            gt_translate()     translation_queue
                                            (instant)          status = pending
                                                    │                │
                                                    │                ▼
                                                    │        cron_process_translations()
                                                    │        (every 60s, batch of 5)
                                                    │                │
                                                    │        gt_translate() via Google
                                                    ▼                ▼
                                              translations
                                              (unique key per
                                               type/id/column/lang)
                                                    │
                                                    ▼
                                              t_obj() / localize_rows()
   User visits the page ─────────────────────►       on every read site

Hybrid policy ("inline-first, queue fallback"):

CasePath
Short field (< 3000 chars) and Google OKInline — translates on save
Long field (≥ 3000 chars, blog body)Queue → cron processes it
Google returns 429Global pause + queue retries later
Same text as last time (same hash)No-op, doesn't even call Google
New language added to /langBulk enqueue, cron drains gradually

Pieces

1. Source language

content_source_lang($con) returns the code the admin writes content in. It is derived from the app_lang_code setting:

SettingReturns
'es', 'fr', 'de'the same code
'auto' or empty'en' (safe fallback)
uninstalled code'en' (fallback)

Everything else is considered a target language.

2. The catalog

translatable_catalog() in app/functions.php is the single place where translatable fields are declared. Each entry maps an object_type to a table and column:

return [
    'plan_name'         => ['table' => 'plans',         'col' => 'name',        'where' => '1=1'],
    'plan_desc'         => ['table' => 'plans',         'col' => 'description', 'where' => '1=1'],
    'plan_feature'      => ['table' => 'plan_features', 'col' => 'label',       'where' => '1=1'],
    'plan_feature_desc' => ['table' => 'plan_features', 'col' => 'description', 'where' => '1=1'],
    'testimonial_role'  => ['table' => 'testimonials',  'col' => 'role',        'where' => '1=1'],
    'testimonial_body'  => ['table' => 'testimonials',  'col' => 'body',        'where' => '1=1'],
    'blog_title'        => ['table' => 'blog_posts',    'col' => 'title',       'where' => '1=1'],
    'blog_excerpt'      => ['table' => 'blog_posts',    'col' => 'excerpt',     'where' => '1=1'],
    'blog_body'         => ['table' => 'blog_posts',    'col' => 'body',        'where' => '1=1'],
];

Free-form settings are declared in translatable_settings_list():

return ['maintenance_title', 'maintenance_message', 'maintenance_eta', 'seo_default_description', ...];

3. The two tables

translation_queue — the work queue. One row per (object_type, object_id, object_key, lang_code). States: pendingdone or failed. Tracks attempts (max 3) and last_error when something fails. PK is an INT generated by random_id_int(), same convention as the rest of the project.

translations — the final store. Same keys as the queue, plus the translated value and the source_hash (SHA-1 of the source text at translation time). When the admin edits the text, the hash changes → enqueue_translation re-queues the row and the cron processes it again.

4. The translator

app/translator.php talks directly to translate.googleapis.com/translate_a/single?client=gtx, the same public endpoint used by stichoza/google-translate-php and similar libraries. No API key required.

FunctionReturns
gt_translate($text, $to, $from = 'auto')Translated string, or __error array
gt_is_paused($con)true if the global pause setting is active
gt_pause($con, $seconds, $reason)Activates global pause

Robustness details:

5. Translate on save (translate_now)

When the admin saves something in /admin/plans, /admin/settings#testimonials, /admin/blog, /admin/settings#maintenance or /admin/settings#general, the corresponding endpoint calls translate_now($con, $type, $id, $key, $value) for each translatable field. The function:

  1. If the text is empty → does nothing.
  2. If the text is > 3000 characters or the translator is paused (gt_is_paused), it delegates to the classic enqueue_translation(). The queue and the cron take over.
  3. For each target language (every available_languages() minus the source):
    • Check if a translation with the same source_hash already exists → skip, avoid redundant call.
    • Call gt_translate(). On success, runs INSERT … ON DUPLICATE KEY UPDATE in translations and removes any pending row in translation_queue for that (type, id, key, lang).
    • On error (429 in particular), invokes gt_pause(600) and, from that language onward, the next ones are queued instead of continuing to call Google.

6. The worker

cron_process_translations($con, $batch = 5) runs inside run_cron() with a 60-second throttle. Each tick:

  1. Checks gt_is_paused(). If paused, returns.
  2. Runs SELECT … FROM translation_queue WHERE status='pending' AND attempts < 3 ORDER BY updated_at ASC LIMIT 5.
  3. For each row:
    • Retrieves the live source text from the origin table (catalog).
    • If source is empty or the origin row was deleted → removes the queue row (auto-cleanup).
    • If the current hash differs from the one stored in the queue, refreshes the hash.
    • Calls gt_translate().
    • If returns an error with __http=429gt_pause(600s, 'rate-limited') and breaks the loop.
    • Other errors → attempts++ and message in last_error. After 3 attempts moves to failed.
    • Success → INSERT … ON DUPLICATE KEY UPDATE in translations, marks queue as done.

7. New language detection

cron_detect_new_languages($con) compares the translated_languages_seen setting (JSON array) against available_languages(). When a new code appears, it walks the catalog and translatable settings and queues one row per existing text for that language. After queuing, it updates the setting so the next tick doesn't re-scan everything.

8. Reading on pages

Short-circuit: when the current LANG_CODE matches the source language, t_obj() bypasses the DB and returns the fallback. Zero cost for visitors using the admin's language.

Adding a new translatable field

Imagine you add a subtitle column to plans and want it translated.

1. Add to the catalog

In app/functions.php, inside translatable_catalog():

'plan_subtitle' => ['table' => 'plans', 'col' => 'subtitle', 'where' => '1=1'],

2. Translate on save

In assets/ajax/index.php, inside adminPlanSave (both UPDATE and INSERT branches):

translate_now($con, 'plan_subtitle', $id, 'subtitle', $subtitle);

And in adminPlanDelete, for cleanup:

translations_purge_for_object($con, 'plan_subtitle', $id);

3. Render translated

On the public page that displays the subtitle:

$plans = plans_list($con);
localize_rows($con, $plans, [
    'name'        => 'plan_name',
    'description' => 'plan_desc',
    'subtitle'    => 'plan_subtitle',
]);

Done. On save, the translation happens inline if the field is short, or stays queued for the cron if it's long. No need to touch migrations — the translations table is generic.

Diagnostics

"Nothing translates"

  1. Check the cron is running:
    SELECT setting_value, FROM_UNIXTIME(setting_value) FROM settings WHERE setting_name='cron_time';
  2. Check if there is an active pause:
    SELECT setting_value FROM settings WHERE setting_name='translate_paused_until';
    If the value is greater than UNIX_TIMESTAMP(), the cron is paused by a recent 429.
  3. Check if there are pending rows:
    SELECT status, COUNT(*) FROM translation_queue GROUP BY status;
  4. Permanent failed rows: check last_error and re-queue manually:
    UPDATE translation_queue SET status='pending', attempts=0, last_error=NULL WHERE status='failed';

"Translates, but old text still shows"

t_obj() caches per request, not between requests. A reload is enough. If it persists, check the source_hash:

SELECT q.source_hash AS q_hash, t.source_hash AS t_hash, t.value
  FROM translation_queue q
  LEFT JOIN translations t USING (object_type, object_id, object_key, lang_code)
 WHERE q.status='done' AND object_id=<id>;

"Force re-translation of everything to fr"

DELETE FROM translation_queue WHERE lang_code='fr';
DELETE FROM translations      WHERE lang_code='fr';
DELETE FROM settings          WHERE setting_name='translated_languages_seen';

On the next cron tick, cron_detect_new_languages() will see fr as new and re-queue everything.

Admin panel at /admin/settings → Translations tab

The "Translations" tab in the admin panel surfaces:

Design decisions

No Composer. The project ships on CodeCanyon; composer install adds friction for buyers. Google's public endpoint is stable and trivial to consume via cURL.

Queue separate from store. Lets us retry without losing already-done translations, and keep state / last_error without polluting the read table.

5 items per tick + sleep + global pause. Google starts returning 429 around 100 requests/minute per IP. 5 every 60s stays comfortably below the threshold.

Hash instead of version. Detecting changes by hash makes enqueue_translation idempotent: the admin can hit "Save" a thousand times without the cron repeating work.

INT with random_id_int(). Same as the rest of the project. Maintains the convention: never AUTO_INCREMENT on columns we touch from PHP.

Short-circuit in t_obj() when active lang is source. Visitors who use the admin's language don't pay a single extra query for content lookup.

On HTML inside blog body

The gtx endpoint preserves tags and attributes reliably in normal blog HTML: <p>, <strong>, <em>, <h2>/<h3>, <a href>, <ul>/<ol>/<li>, <blockquote>, <img src alt>, etc.

Watch out: relative URLs (e.g. /blog/security) can end up translated too (/blog/seguridad). If you link to internal pages from inside a post body, use absolute URLs (https://…) — Google leaves those alone.
FortPass · © 2026 Medel Platforms · medel.es