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"):
| Case | Path |
|---|---|
| Short field (< 3000 chars) and Google OK | Inline — translates on save |
| Long field (≥ 3000 chars, blog body) | Queue → cron processes it |
| Google returns 429 | Global pause + queue retries later |
| Same text as last time (same hash) | No-op, doesn't even call Google |
| New language added to /lang | Bulk 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:
| Setting | Returns |
|---|---|
'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: pending → done 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.
| Function | Returns |
|---|---|
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:
- Splits inputs larger than 4500 characters respecting punctuation.
- 350 ms pause between calls in the same process.
- Detects
429and lets the caller invokegt_pause()(10 min default) → the next cron run skips the work without calling Google. - Special code mapping (
zh-cn→zh-CN,pt-br→pt, etc.).
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:
- If the text is empty → does nothing.
- If the text is > 3000 characters or the translator is paused (
gt_is_paused), it delegates to the classicenqueue_translation(). The queue and the cron take over. - For each target language (every
available_languages()minus the source):- Check if a translation with the same
source_hashalready exists → skip, avoid redundant call. - Call
gt_translate(). On success, runsINSERT … ON DUPLICATE KEY UPDATEintranslationsand removes any pending row intranslation_queuefor 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.
- Check if a translation with the same
6. The worker
cron_process_translations($con, $batch = 5) runs inside run_cron() with a 60-second throttle. Each tick:
- Checks
gt_is_paused(). If paused, returns. - Runs
SELECT … FROM translation_queue WHERE status='pending' AND attempts < 3 ORDER BY updated_at ASC LIMIT 5. - 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=429→gt_pause(600s, 'rate-limited')and breaks the loop. - Other errors →
attempts++and message inlast_error. After 3 attempts moves tofailed. - Success →
INSERT … ON DUPLICATE KEY UPDATEintranslations, marks queue asdone.
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
t_obj($con, $type, $id, $key, $fallback)— returns the translated value for the activeLANG_CODE, or thefallbackif no translation exists. Loads once per request all translations for the active language into a static cache.t_setting_localized($con, $name, $default)— same but for settings (object_type='setting',object_id=0).localize_rows($con, $rows, $map)— walks an array of rows and overwrites in-place the columns indicated by$map(column => object_type).localize_row($con, $row, $map)— single-row version (returns the modified row).
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"
- Check the cron is running:
SELECT setting_value, FROM_UNIXTIME(setting_value) FROM settings WHERE setting_name='cron_time'; - Check if there is an active pause:
If the value is greater thanSELECT setting_value FROM settings WHERE setting_name='translate_paused_until';UNIX_TIMESTAMP(), the cron is paused by a recent 429. - Check if there are pending rows:
SELECT status, COUNT(*) FROM translation_queue GROUP BY status; - Permanent
failedrows: checklast_errorand 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:
- Source language and list of target languages.
- Counters: pending / translated / failed.
- Last worker run and status (active / paused-until-X).
- Six action buttons:
- Run now — forces a worker tick (batch of 5).
- Process all — loops the worker until empty or 30s deadline.
- Detect new languages — calls
cron_detect_new_languagesimmediately. - Re-queue all — walks the catalog + settings and re-queues every text.
- Retry failed — moves
failedrows topending. - Restart translator — only visible when paused; lifts the pause.
- Unified history table with the latest queued / processed rows (source text + colored status pill).
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.
/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.