A unique combination of foundational modules for Silverstripe websites.
composer require "akqa/silverstripe-tumu"Tumu is designed for a few purposes:
- To make our projects consistent and predictable as possible.
- Simplify upgrading between major platform versions.
- DRY - Don't Repeat Yourself.
By replacing direct composer requirements of things like cms and elemental
and using tumu we can delegate some of the heavy lifting to a single source of
truth and handle major version upgrades with a single dependency
- Silverstripe 6 CMS
- Common functionality such as
TagFieldandTaxonomywhich we widely use. - Elemental
- UserForms
- Menu Manager
- Redirects
- SEO related improvements (sitemaps, robots)
- Vite and SSR integration for building modern front-ends.
- Extensions for common functionality (Sorting, )
- 🔨more to come
Ideal situation is your project specific composer.json reflects something as
simple as
{
"name": "akqa/project",
"type": "project",
"require": {
"akqa/silverstripe-tumu": "dev-main"
},
"require-dev": {
"cambis/silverstan": "^2",
"phpro/grumphp-shim": "^2",
"phpunit/phpunit": "^11",
"squizlabs/php_codesniffer": "^3.13",
"php-parallel-lint/php-parallel-lint": "^1.4"
},
"scripts": {
"test": "php -d memory_limit=-1 ./vendor/bin/phpunit",
"phpstan": "phpstan analyse --memory-limit 1024M",
"lint": "phpcs app/src app/tests",
"lint:fix": "phpcbf app/src app/tests",
"coverage": "XDEBUG_MODE=coverage php -d memory_limit=-1 ./vendor/bin/phpunit --coverage-text"
},
"config": {
"platform": {
"php": "8.4"
}
},
"minimum-stability": "dev",
"prefer-stable": true
}The dev dependencies are used for GrumPHP
and our standard linting rules.
For this we assume a default location of javascript, css and images of
app/client. You can use themes if you wish but most of our sites use a single
app directory. Under the app/client we usually structure it as
app/client
/src
/js
/css
/dist
.. vite controlled output
Tumu provides a ViteProvider trait which extracts our handling of importing
Vite requirements (but not the running of the Vite process, you will need to
update your environment to physically build or run the hot-reload server).
<?php
use Akqa\SilverStripe\Traits\ViteProvider;
class PageController extends ContentController
{
use ViteProvider;
}In your Page.ss template include the following <% include Vite %>. By
default, this will handle the hot-reload and requirements for 2 entry points
app/client/src/index.ts and app/client/src/index.css. To rename or change
these entrypoints, use the API on ViteProvider
<?php
use Akqa\SilverStripe\Traits\ViteProvider;
class PageController extends ContentController
{
use ViteProvider;
protected function init()
{
parent::init();
$this->setPackageManager('pnpm');
$this->setDefaultCssAsset('app/client/src/style.css')
}
}To include additional CSS or JavaScript files (such as print stylesheets),
implement the getAdditionalRequirements() method:
<?php
use Akqa\SilverStripe\Traits\ViteProvider;
class PageController extends ContentController
{
use ViteProvider;
/**
* @return array<string>
*/
public function getAdditionalRequirements(): array
{
return [
'app/client/src/print.css' => [
'media' => 'print'
],
'app/client/src/additional.jsx'
];
}
}The getAdditionalRequirements() method should return an array of asset paths
relative to your Vite source directory. CSS files (.css or .scss) will be
automatically included in the page requirements, while JavaScript files will be
loaded as modules. This is useful for including print stylesheets, page-specific
styles, or additional JavaScript modules.
Vite's production HTML is typically a tiny inline module:
<script type="module">import '/_resources/app/client/dist/index-….js'</script>Any file listed in that entry's imports in manifest.json (for example a
Registry chunk, or a shared vendor chunk) is only discovered after index.js
downloads and parses — an extra round trip on the critical path.
ViteProvider emits <link rel="modulepreload"> for those JS imports (and for
imports of any additional JS entries from getAdditionalRequirements()). CSS
imports are unchanged: they still go through Requirements::css(). No project
code is required beyond using <% include Vite %>.
If you render Includes/ViteRequirements yourself, pass ModulePreloads as well
as JSModules:
return $this->renderWith('Includes/ViteRequirements', [
'JSModules' => $jsModules,
'ModulePreloads' => $this->getViteModulePreloads($manifest, [
$this->getDefaultJsAsset(),
]),
]);Tumu can optionally server-render React "islands" into Silverstripe templates, then hydrate them with the same Vite client bundle. This is opt-in: client-only mount nodes keep working without it.
The approach is deliberately small. Silverstripe still owns the page. React
renders named components (a banner, an accordion, a listing) rather than the
whole site. PHP asks Node.js to renderToString a component from a Vite SSR
bundle, wraps that HTML in a mount node, and the browser hydrates it.
Silverstripe template
│
▼
$ReactComponent('Banner', $BannerProps)
│
├─ SSR off / Vite HMR / Node failure
│ → <div data-component="Banner" data-props="{...}"></div>
│
└─ SSR on
→ Node runs app/client/dist/ssr.js
→ <div data-component="Banner" data-props="{...}"><h1>...</h1></div>
│
▼
Client bundle hydrates [data-component] (hydrateRoot if the node has markup,
createRoot if it is empty)
- Node.js must be on the PATH of the PHP process (DDEV's web container is fine; many PHP-only hosts are not).
- Build a Vite SSR entry that reads JSON from stdin and writes HTML to stdout (see below).
- Turn it on:
SS_REACT_SSR_ENABLED="true"
Or in YAML:
Akqa\SilverStripe\SSR\ReactRenderer:
enabled: true
entry: app/client/dist/ssr.js
node_binary: node
timeout_ms: 5000
fallback_on_error: true
skip_when_vite_hot: trueOptional env overrides: SS_REACT_SSR_ENTRY, SS_REACT_SSR_NODE,
SS_REACT_SSR_TIMEOUT (milliseconds).
Build props in PHP (JSON in .ss files is miserable):
public function getBannerProps(): string
{
return json_encode([
'title' => (string) $this->Title,
], JSON_THROW_ON_ERROR);
}<% cached 'banner', $ID, $LastEdited %>
$ReactComponent('Banner', $BannerProps)
<% end_cached %>That prints a mount node using the same data-component / data-props
attributes as the Vite starter,
so existing client registries keep working. The global is already cast as
HTMLFragment — do not add .RAW.
From PHP:
use Akqa\SilverStripe\SSR\ReactRenderer;
$html = ReactRenderer::singleton()->render('Banner', [
'title' => $this->Title,
]);Partial caching around $ReactComponent is strongly recommended. Each SSR call
spawns Node; caching the Silverstripe fragment is how this stays cheap.
Tumu ships the PHP side only. The project owns the React registry, the SSR entry, and hydration.
SSR entry (app/client/src/ssr.tsx) — write to stdout with
process.stdout.write, never console.log (a trailing newline becomes a text
node and hydration warns):
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { registry } from "./state/Registry";
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
process.stdin.on("end", () =>
resolve(Buffer.concat(chunks).toString("utf8"))
);
process.stdin.on("error", reject);
});
}
readStdin()
.then((raw) => {
const { component, props } = JSON.parse(raw) as {
component: string;
props: Record<string, unknown>;
};
const Component = registry[component];
if (!Component) {
process.stderr.write(`Unknown React component: ${component}\n`);
process.exit(1);
}
process.stdout.write(renderToString(createElement(Component, props)));
})
.catch((error) => {
process.stderr.write(String(error));
process.exit(1);
});PHP sends {"component":"Banner","props":{...}} on stdin.
Client hydration (app/client/src/index.tsx):
import { createElement } from "react";
import { createRoot, hydrateRoot } from "react-dom/client";
import { registry } from "./state/Registry";
document.querySelectorAll<HTMLElement>("[data-component]").forEach((el) => {
const name = el.dataset.component;
const Component = name ? registry[name] : undefined;
if (!Component) {
return;
}
const props = JSON.parse(el.dataset.props || "{}");
const node = createElement(Component, props);
if (el.hasChildNodes()) {
hydrateRoot(el, node);
} else {
createRoot(el).render(node);
}
});Vite SSR build (second config so it does not wipe the client dist). Bundle
dependencies (ssr.noExternal: true) so production only needs the generated
file and node, not node_modules on the web host:
// vite.ssr.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: { "@": path.resolve(__dirname, "./app/client/src/") },
},
ssr: {
noExternal: true,
},
publicDir: false,
build: {
ssr: "./app/client/src/ssr.tsx",
outDir: "./app/client/dist",
emptyOutDir: false,
copyPublicDir: false,
rollupOptions: {
output: {
format: "cjs",
entryFileNames: "ssr.js",
},
},
},
});{
"scripts": {
"build": "vite build && vite build --config vite.ssr.config.ts"
}
}When SS_USE_VITE_DEV_SERVER=true, Tumu skips SSR and emits an empty mount
node. HMR stays fast and you avoid hydrating against a stale SSR bundle. To
exercise SSR locally, build assets and set SS_USE_VITE_DEV_SERVER=false.
PHPUnit talks to a real Node process and react-dom/server when Node is on
PATH and fixture deps are installed:
npm ci --prefix tests/SSR/fixtures
vendor/bin/phpunit --group nodeIf Node or tests/SSR/fixtures/node_modules is missing, those tests are
skipped. CI installs Node and the fixtures and sets SSR_INTEGRATION=1 so a
skip becomes a failure. Successful SSR must include the React markup inside the
mount node; a thrown render, unknown component, or invalid bundle must fall
back to an empty client mount.
Treat these as product constraints, not temporary gaps:
- Node at request time. PHP must be able to
proc_opennode. This is normal on DDEV; it is not true of PHP-only PaaS images unless you add Node to the web container. Tumu will not SSH to a remote Node service or talk HTTP to a sidecar. - Not a React meta-framework. No file-based routing, streaming
(
renderToPipeableStream), RSC, or data loaders. Silverstripe remains the server. SSR here isrenderToStringfor islands. - Components must be isomorphic. No
window,document,localStorage, or layout reads during the first render.useEffect/useLayoutEffectdo not run on the server.Date.now()andMath.random()cause hydration mismatches — keep them in effects. - Browser-only libraries. Maps, carousels, and similar that assume a DOM should stay client-only (empty mount node). SSR the content-ish islands (headings, listings, FAQ copy); hydrate the widgets.
- CSS-in-JS and CSS modules need extra SSR wiring (style collection, class name stability). Prefer CSS imported through the Vite client bundle.
- JSON-serialisable props only. No functions, class instances, or
DataObjects. Do not put secrets in props — they are printed into
data-propsin the HTML. - Hydration must match. If SSR HTML and the client first render differ, React will warn and may discard markup. Keep the registry identical on both entries.
- Process cost. Spawning Node per component is slow relative to PHP. Cache the Silverstripe fragment. Do not SSR every icon or toggle.
- Failure mode. With
fallback_on_error(the default) a missing bundle, timeout, or unknown component is logged and the empty mount node is returned so the client can still render. Setfallback_on_error: falsein dev if you want that to throw. proc_openmust be allowed. Disabledproc_openinphp.inimakes SSR impossible.- No streaming or chunked HTML. The PHP request waits for the full string (default timeout 5s).
If those limits are a problem for a given component, skip SSR and keep the
client-only data-component mount. That is the supported default.
It should not be treated as a dumping ground for every and all clever ideas someone has. Features (especially composer ones) should be added with some level of skepticism as to whether they will practically be used by all our clients. Features such as TagField and LinkField is fine as any usage is 'opt-in' for specific sites.
Modules such as Subsites or Translatable haven't been included since we perhaps use them in less than half of the active supported clients and these dramatically alter the CMS interface.
There is also an assumption that this is used for your typical stock standard website and not slightly more left field projects (i.e framework-only).
Copyright 2025 AKQA NZ Limited
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
-
Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
-
Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
-
Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.