SilverStripe 4.10 with Fluent 6 and Static Publisher

Hello All,

  • I’m testing Silverstripe: 4.10 + Fluent 6 + StaticPublisher 5.2*

It should be a good package, but it doesn’t work. In this thread I will try to bring you closer to the topic - maybe we can do it together

via terminal, via task, after generate all pages (100%) (49 pages) with current selected language, memory leaks and stops – appear. Can’t go for other lang. Need to test without Fluent as I remember – always have a little problems.

See this in action:
Show in action

[2022-07-17 13:42:48][INFO] Building **46** URLS
[2022-07-17 13:42:48][INFO] array (
0 => 'http://localhost/axonnite/pl/?stage=Live',
1 => 'http://localhost/axonnite/pl/zastosowania/kosmetyka/?stage=Live',
...
44 => 'http://localhost/axonnite/pl/page-not-found/?stage=Live',
45 => 'http://localhost/axonnite/pl/server-error/?stage=Live',
)

[2022-07-17 13:44:20][INFO] <br />
<b>Fatal error</b>: Allowed memory size of 536870912 bytes exhausted (tried to allocate 20480 bytes) in <b>/Users/.../axonnite/vendor/symfony/var-exporter/Internal/Registry.php</b> on line <b>43</b><br />

After last Page, Queue still running :confused: until memory limit stops.
I would not like to quit, it would be good if it worked - as it should! I have always been awesome, it is a must have in every project.

Thanks for any help!
:wrench:

EDIT:

Some tests

// StaticCacheFullBuildJob.php to see all locales
foreach (Locale::getLocales() as $locale) {

            $state = FluentState::singleton();
            $state->setLocale($locale->getLocale());
            $livePages = Versioned::get_by_stage(SiteTree::class, Versioned::LIVE);

            foreach ($livePages as $page) {
                if ($page->hasExtension(PublishableSiteTree::class) || $page instanceof StaticallyPublishable) {
                    $urls = array_merge($urls, $page->urlsToCache());
                }
            }
            
        }

Now /cache has /en and /pl pages – ok, first steep done. Now Handling Requests by SilverStripe, Using .htaccess :thinking:

EDIT 2:

// /public/.htaccess

# Config for site in a subdirectory
	# (remove the following four rules if site is on root of a domain. E.g. test.com rather than test.com/my-site)
	# Cached content - sub-pages (site in sub-directory)
	RewriteCond %{REQUEST_METHOD} ^GET|HEAD$
	RewriteCond %{QUERY_STRING} ^$
	#RewriteCond %{REQUEST_URI} /(.*[^/])/(.*[^/])/?$
	RewriteCond %{REQUEST_URI} /(.*[^/])/(.*[^/])/?/(.*[^/])/?$
	RewriteCond %{DOCUMENT_ROOT}/%1/cache/%2/%3.html -f
	RewriteCond %{REQUEST_FILENAME} !-f
	RewriteRule .* cache/%2/%3.html [L]

	# Cached content - homepage (site in sub-directory)
	RewriteCond %{REQUEST_METHOD} ^GET|HEAD$
	RewriteCond %{QUERY_STRING} ^$
	RewriteCond %{REQUEST_URI} ^/(.*[^/])/?$
	RewriteCond %{DOCUMENT_ROOT}/%1/cache/index.html -f
	RewriteCond %{REQUEST_FILENAME} !-f
	RewriteRule .* cache/index.html [L]

Testing… but now fetch good .html

EDIT 3:

Sitll is problem with links ?stage=Live attribute and others but steep by steep.

// PublishableSiteTree.php after add VersionedMode – looks nice, but don’t work.
// Ok, via sake dev/tasks/ProcessJobQueueTask – ‘?stage=Live’ is gone.

public function urlsToCache() {
        //return [Director::absoluteURL($this->getOwner()->Link()) => 0];

        return Versioned::withVersionedMode(function () {
            Versioned::set_reading_mode(Versioned::LIVE);
            return [Director::absoluteURL($this->getOwner()->Link()) => 0];
        });
    }

EDIT 4: For testing, I changed php to 8.1 – and update to 4.11+, some error:

Uncaught Exception TypeError: "Symbiote\QueuedJobs\Services\QueuedJobService::handleBrokenJobException(): Argument #2 ($job) must be of type Symbiote\QueuedJobs\Services\QueuedJob, null given
Fresh install SilverStripe 4.11 + StaticPublisher · Issue #379 · symbiote/silverstripe-queuedjobs · GitHub (

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:

  1. Would an additional extension cause overhead because the original urlsToCache() is still called?
  2. 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: SiteTreePublishingEngineobjectsToUpdate() → 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!

1 Like

Thanks @wmk — your reply actually made me improve my own code! Two things I immediately adopted:

1. $page->Locales() + getAbsoluteLink() instead of manual iteration

You’re right that Fluent’s built-in API is much cleaner. I dug into the RecordLocale source and getRecord() already does FluentState::withState() + DB re-fetch internally — so it eliminates my “Bug #2” workaround automatically. My urlsToCache() went from 40 lines to 15:

public function urlsToCache(): array
{
    $page = $this->getOwner();

    if (!$page->hasMethod('Locales')) {
        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];
        });
    }

    return Versioned::withVersionedMode(function () use ($page) {
        Versioned::set_reading_mode(Versioned::LIVE);

        $urls = [];
        foreach ($page->Locales() as $localeInfo) {
            try {
                $link = $localeInfo->getAbsoluteLink();
                if ($link) {
                    $urls[$link] = 0;
                }
            } catch (\Exception $e) {}
        }

        gc_collect_cycles();
        return $urls;
    });
}

2. The staticrequesthandler.php in index.php :exploding_head:

Great tip! I was using .htaccess rewrite rules, but the PHP handler from the SS6 docs gives you ETag/304 support, X-Cache-Hit/X-Cache-Miss headers, and the bypassStaticCache cookie for CMS editors — all for free. Just adopted it:

// public/index.php — before Composer autoload
require_once '../vendor/silverstripe/staticpublishqueue/includes/functions.php';
$requestHandler = require '../vendor/silverstripe/staticpublishqueue/includes/staticrequesthandler.php';

if ($requestHandler('cache') !== false) {
    die;
} else {
    header('X-Cache-Miss: ' . date(\DateTime::COOKIE));
}

// Full SilverStripe bootstrap only on cache miss
require '../vendor/autoload.php';
// ... rest of index.php

The key insight: on a cache hit, Composer autoloader never loads. On a cache miss, you still get the X-Cache-Miss header for debugging.


A couple of things I noticed in your code that might be worth checking:

The flag reset — your afterGetAllLivePageURLs() has a comment but no code:

public function afterGetAllLivePageURLs()
{
    //remove flag to get all locales
}

If QueuedJobs ever reuses the process (e.g. --daemon mode), the $getAllLocales static property stays true. Probably worth adding FluentPublishableSiteTree::setGenerateAllLocales(false).

?stage=Live in URLsRecordLocale::getAbsoluteLink() calls $record->Link() without Versioned::withVersionedMode(). From CLI context, SilverStripe can append ?stage=Live. I’d wrap the Locales loop in Versioned::withVersionedMode() as shown above.


On your point about CloudFlare / nginx cache as alternatives — that’s definitely the direction for high-traffic sites. For our use case (shared hosting, no root access, ~30 pages), the filesystem cache with the PHP handler hits the sweet spot. But it’s good to know the options.

Thanks again for sharing your approach — the community benefits when we compare notes like this!