StaticPublishQueue + Fluent: Multi-Locale Static Cache (Complete Working Solution)
Following up on this thread and a recent Slack discussion where @wmk and @andante were working through the same problem — I’ve had this running in production for a few weeks now on SilverStripe 5 with Fluent 7 (8 locales, 30+ pages, 370+ cached HTML files), so I wanted to share the complete solution including the gotchas I ran into.
The Problem
Out of the box, staticpublishqueue only caches the current locale’s URL. With Fluent, each page has a unique URL per locale (e.g. /rehabilitacja-reki, /en/hand-rehabilitation, /de/handrehabilitation). The StaticCacheFullBuildTask doesn’t know about Fluent locales, so translated pages never get cached.
@wmk raised two important questions on Slack:
- Would an additional extension cause overhead because the original
urlsToCache() is still called?
- Do you need a flag to distinguish “we’re in the full rebuild task” vs “CMS publish”?
The answer to both is no — and here’s why.
The Approach: Additional Extension (not a replacement)
When multiple extensions implement the same method, SilverStripe resolves via a method chain — the last extension in config order gets called. So if your Fluent-aware extension is loaded After: '#staticpublishqueue', your urlsToCache() runs instead of the original PublishableSiteTree::urlsToCache(). No overhead, no double execution.
But critically: do NOT remove PublishableSiteTree!
Looking at the source (StaticCacheFullBuildJob.php line ~122):
if ($page->hasExtension(PublishableSiteTree::class) || $page instanceof StaticallyPublishable) {
$urls = array_merge($urls, $page->urlsToCache());
}
The job checks hasExtension(PublishableSiteTree::class) to decide which pages to process. Remove it and the job collects 0 URLs, goes straight to cleanup, and crashes. Keep it there — it just won’t have its urlsToCache() called because yours runs instead (last wins).
You also don’t need a flag to distinguish “full rebuild” vs “CMS publish”. Your urlsToCache() always returns all locale URLs for the given page. The distinction happens at a higher level:
- CMS publish:
SiteTreePublishingEngine → objectsToUpdate() → returns only the published page → urlsToCache() generates all locale URLs for that one page
- Full rebuild:
StaticCacheFullBuildJob iterates ALL pages → calls urlsToCache() per page → same method, same result
No flag, no global state, no race conditions.
The Extension
<?php
namespace App\Extensions;
use SilverStripe\CMS\Model\RedirectorPage;
use SilverStripe\CMS\Model\SiteTree;
use SilverStripe\Control\Director;
use SilverStripe\ORM\DataExtension;
use SilverStripe\StaticPublishQueue\Contract\StaticallyPublishable;
use SilverStripe\StaticPublishQueue\Contract\StaticPublishingTrigger;
use SilverStripe\StaticPublishQueue\Extension\Engine\SiteTreePublishingEngine;
use SilverStripe\StaticPublishQueue\Extension\Publishable\PublishableSiteTree;
use SilverStripe\Versioned\Versioned;
use TractorCow\Fluent\Model\Locale;
use TractorCow\Fluent\State\FluentState;
/**
* Fluent-aware static cache: generates URLs for ALL locales.
* Add AFTER PublishableSiteTree (don't remove it — see notes above).
*/
class FluentStaticPublishExtension extends DataExtension implements StaticallyPublishable, StaticPublishingTrigger
{
public function objectsToUpdate($context)
{
$list = [];
$siteTree = $this->getOwner();
if ($context['action'] === SiteTreePublishingEngine::ACTION_PUBLISH) {
$list[] = $siteTree;
$regenerateChildren = SiteTree::config()->get('regenerate_children');
$forceRecursive = $context['urlSegmentChanged'] ?? false;
if ($regenerateChildren !== PublishableSiteTree::REGENERATE_RELATIONS_NONE || $forceRecursive) {
$recursive = ($regenerateChildren === PublishableSiteTree::REGENERATE_RELATIONS_RECURSIVE)
|| $forceRecursive;
foreach ($siteTree->Children() as $child) {
$list[] = $child;
if ($recursive) {
$this->addChildrenRecursive($list, $child);
}
}
}
}
$regenerateParents = SiteTree::config()->get('regenerate_parents');
if ($regenerateParents !== PublishableSiteTree::REGENERATE_RELATIONS_NONE) {
$parent = $siteTree->Parent();
if ($parent && $parent->exists()) {
$list[] = $parent;
}
}
return $list;
}
public function objectsToDelete($context)
{
if ($context['action'] !== SiteTreePublishingEngine::ACTION_UNPUBLISH) {
return [];
}
return [$this->getOwner()];
}
/**
* Generate cache URLs for ALL Fluent locales.
*
* Instead of caching only the current locale, iterate all configured locales
* and generate the localized URL for each.
*/
public function urlsToCache(): array
{
$urls = [];
$page = $this->getOwner();
$pageID = $page->ID;
// Graceful fallback if Fluent isn't installed
if (!class_exists(Locale::class)) {
return $this->singleLocaleUrls($page);
}
$locales = Locale::getLocales();
if (!$locales || !$locales->exists()) {
return $this->singleLocaleUrls($page);
}
foreach ($locales as $locale) {
FluentState::singleton()->withState(function (FluentState $state) use ($pageID, &$urls, $locale) {
$state->setLocale($locale->Locale);
// FIX #1: Prevent ?stage=Live in cached URLs
Versioned::withVersionedMode(function () use ($pageID, &$urls) {
Versioned::set_reading_mode(Versioned::LIVE);
// FIX #2: Re-fetch page to get localized URLSegment.
// The original $page still has default-locale fields in memory.
$localPage = SiteTree::get()->byID($pageID);
if (!$localPage) {
return;
}
try {
$link = ($localPage instanceof RedirectorPage)
? $localPage->regularLink()
: $localPage->Link();
if ($link) {
$urls[Director::absoluteURL($link)] = 0;
}
} catch (\Exception $e) {
// Skip locales where this page doesn't exist
}
});
});
// FIX #3: Prevent memory buildup across many locales
gc_collect_cycles();
}
return $urls;
}
private function singleLocaleUrls(SiteTree $page): array
{
return Versioned::withVersionedMode(function () use ($page) {
Versioned::set_reading_mode(Versioned::LIVE);
$link = ($page instanceof RedirectorPage) ? $page->regularLink() : $page->Link();
return [Director::absoluteURL($link) => 0];
});
}
private function addChildrenRecursive(array &$list, SiteTree $page): void
{
foreach ($page->Children() as $child) {
$list[] = $child;
$this->addChildrenRecursive($list, $child);
}
}
}
Config
# app/_config/staticpublish.yml
---
Name: my-staticpublish
After: '#staticpublishqueue'
---
SilverStripe\Core\Injector\Injector:
SilverStripe\StaticPublishQueue\Publisher:
class: SilverStripe\StaticPublishQueue\Publisher\FilesystemPublisher
properties:
fileExtension: html
SilverStripe\StaticPublishQueue\Publisher\FilesystemPublisher:
disallowed_status_codes:
- 404
- 500
# See "Bonus bug" section below
SilverStripe\View\SSViewer:
rewrite_hash_links: false
# Add AFTER PublishableSiteTree — don't remove it, just override urlsToCache()
SilverStripe\CMS\Model\SiteTree:
extensions:
fluent_static_publish: App\Extensions\FluentStaticPublishExtension
Three Bugs You’ll Hit
These are marked as FIX #1–#3 in the code above.
Bug 1: ?stage=Live appended to cached URLs
Without Versioned::withVersionedMode(), SilverStripe appends ?stage=Live to generated links when running from CLI context. Your .htaccess rewrite rules check for an empty query string, so cached pages are never served. @guci0 ran into the same issue in the original thread.
Fix: Wrap all Link() calls in Versioned::withVersionedMode() with set_reading_mode(LIVE).
Bug 2: All locales get the default locale’s URLSegment
This one is subtle. When you change FluentState to en_US, the $page object you already loaded still has the default locale’s fields in memory. Fluent intercepts database queries, not cached DataObject properties. So $page->Link() returns the default-locale URL for every locale.
Fix: Re-fetch the page with SiteTree::get()->byID($pageID) inside each locale’s state closure.
Bug 3: OOM with many locales
Iterating 8 locales × 30 pages creates hundreds of DataObject instances. PHP’s garbage collector doesn’t collect them within the closure scope. On shared hosting with 256MB limit (common on providers like Progreso.pl), ProcessJobQueueTask will crash.
Fix: Call gc_collect_cycles() after each locale iteration. Also run the job with -d memory_limit=1G:
php -d memory_limit=1G vendor/silverstripe/framework/cli-script.php \
dev/tasks/ProcessJobQueueTask
Note: ProcessJobQueueTask processes in batches (~2 min/round). With 8 locales and 30 pages you’ll need 5–6 rounds. Keep running it until it returns “No new jobs on queue”.
Bonus Bug: Corrupted Anchor Links
This one isn’t Fluent-specific but will bite anyone using StaticPublishQueue. SilverStripe’s SSViewer has a rewrite_hash_links feature that prepends $_SERVER['REQUEST_URI'] to every href="#..." in templates (SSViewer.php line ~699).
When ProcessJobQueueTask generates the static HTML from CLI, REQUEST_URI gets set to the cached page’s path. This means every anchor link in your cached HTML gets the path hardcoded:
<!-- What you wrote in the template: -->
<a href="#pricing">Pricing</a>
<!-- What gets cached: -->
<a href="/en/my-page/#pricing">Pricing</a>
This looks correct at first glance, but breaks when the same template partial is included on different pages, or when redirect rules are involved.
Fix: SilverStripe\View\SSViewer: rewrite_hash_links: false
.htaccess Rewrite Rules
Add before the SilverStripe rewrite rules:
# --- StaticPublishQueue Cache ---
RewriteCond %{REQUEST_METHOD} GET
RewriteCond %{QUERY_STRING} ^$
RewriteCond %{REQUEST_URI} !^/(admin|dev|Security|api)
RewriteCond %{DOCUMENT_ROOT}/public/cache/%{REQUEST_URI}.html -f
RewriteRule ^(.*)$ /public/cache/%{REQUEST_URI}.html [L]
# Homepage
RewriteCond %{REQUEST_METHOD} GET
RewriteCond %{QUERY_STRING} ^$
RewriteCond %{DOCUMENT_ROOT}/public/cache/index.html -f
RewriteRule ^$ /public/cache/index.html [L]
Note: On shared hosting, verify what DOCUMENT_ROOT actually points to. Some hosts set it to the project root (www/), others to www/public/. Your rewrite paths must match.
Cache Rebuild After Deploy
Static cache does NOT auto-refresh from database changes or code deploys — only CMS “Publish” triggers a per-page rebuild. After deploying new code or running direct SQL updates:
# Clear existing cache
rm -rf public/cache/*
# Queue full rebuild
php -d memory_limit=1G vendor/silverstripe/framework/cli-script.php \
dev/tasks/SilverStripe-StaticPublishQueue-Task-StaticCacheFullBuildTask
# Process queue (repeat until "No new jobs")
php -d memory_limit=1G vendor/silverstripe/framework/cli-script.php \
dev/tasks/ProcessJobQueueTask
Recommended CRON (hourly safety net):
0 * * * * cd /path/to/project && php -d memory_limit=1G vendor/silverstripe/framework/cli-script.php dev/tasks/ProcessJobQueueTask > /dev/null 2>&1
Results
- 30 pages × 8 locales = 370+ static HTML files
- Response time: 50–70ms (vs 350–500ms without cache) — 5–10× faster
- Cache auto-rebuilds on CMS publish (per page, all locales at once)
- Hourly CRON as safety net
Requirements
silverstripe/framework ^5
silverstripe/staticpublishqueue ^6
tractorcow/silverstripe-fluent ^7
symbiote/silverstripe-queuedjobs
Should also work on SS4 with fluent ^6 and staticpublishqueue ^5 (the same principles apply, as @guci0’s original thread showed), though I’ve only tested on SS5.
Hope this saves someone the few days of debugging it took me. Happy to answer questions!