pbFenom
Templates are rendered by pbFenom — our fork of Fenom, shipped as the Composer package pageblocks/fenom. It is a required dependency, not an option: View type-hints \pbFenom and builds the engine through \pbFenom::factory().
"require": {
"pageblocks/fenom": "^1.0"
}The template language itself is unchanged. Everything you know about Fenom syntax — {if}, {foreach}, modifiers, {extends}, {include} — works the same way. What changed is underneath.
What is where
| Page | About |
|---|---|
| Quick start | Render your first template |
| Template types | Chunk, file, MODX template — and where they live |
| Settings | Cache, auto-reload, directories |
| Available data | What a template already has, and global variables |
| Modifiers | The built-in ones and your own |
| Pseudo-tags | Calling a function as a tag |
| Block tags | Your own paired constructs |
| PHP functions | What a template is allowed to call |
| Output processing | What happens to the finished HTML |
| Queries in a template | query() right in the markup |
Where to declare things
Your own modifiers, tags and data live in the site layer, not in the component — a component update leaves them alone:
core/App/Helpers/fenom/
├── modifiers.php ← modifiers
├── inline_tags.php ← pseudo-tags
├── block_tags.php ← block tags
├── data.php ← global data
└── php_functions.php ← allowed PHP functionsIt is not a drop-in for upstream
The fork lives in its own namespace: the class is pbFenom, the interfaces are pbFenom\ProviderInterface and friends. You cannot swap in fenom/fenom and you cannot install both and expect them to be the same engine.
That isolation is the point — the component pins behaviour it depends on instead of tracking a moving upstream.
Why the fork exists
eval() is gone
Upstream rendered through eval() in several modes — FORCE_COMPILE even wrote the compiled file to disk and then ignored it. The fork includes the artifact it just wrote, so opcache can keep the opcodes, and the two modes that have no file by design render through a private stream wrapper instead.
Practical effect: a runtime error names the template instead of reporting eval()'d code on line N.
Modifiers accept null again
Upstream 3.0.0 added parameter types to the modifiers, which turned an ordinary null template variable into a fatal:
{$x} {* fine - htmlspecialchars(null) only warns *}
{$x|escape} {* upstream 3.0.0: fatal *}In a CMS a null field is normal, not exceptional. escape, unescape, truncate, strip, replace, ereplace, match, ematch and date treat null as "", the way they did before 3.0.0.
Recursion is bounded
A cyclic {include} used to recurse until PHP exhausted the call stack, and a cyclic {insert} until it exhausted memory. Both are fatal errors, so they are uncatchable: the visitor got a blank 500 with nothing in the log worth reading.
Now both raise an ordinary exception in milliseconds and name the template. The limit is pbFenom::$max_template_depth (32). Cost on the hot path: 1.8% on a loop of 1000 {include}s.
Nested render errors are also no longer re-wrapped once per level — messages used to read "unhandled exception in a: unhandled exception in b: ...".
addFunction() keeps its contract
The callback receives ($params, $tpl, $var), as it always has. If you want the callable's own signature to be the template API, register it with addFunctionSmart() — which, in the fork, finally accepts closures, array callables and invokable objects rather than strings only.
A missing required argument to a smart function is now reported at compile time ("Function excerpt requires the 'text' argument") instead of blowing up mid-render.
Cache signature actually covers the configuration
Registering functions did not invalidate the compile-cache signature, so two engine instances differing only in a registered function could serve each other's compiled artifacts. The signature now covers that, plus a CACHE_FORMAT constant so artifacts from an older code generator are not reused.
Strict types
All 17 source files declare strict_types=1, which surfaced four latent bugs that weak mode had been papering over — among them getProvider() receiving a bool on every template load, because strstr() returns false rather than null when a template name carries no schema.
Compiled artifacts deliberately do not declare strict types: template data is arbitrary, and coercing an int handed to a string-typed modifier is documented behaviour.
Speed
The fork was not chasing upstream but its own bottlenecks, so the numbers below are before-and-after on the same bench, not a comparison with other engines.
| What | Before | After |
|---|---|---|
Compiling a megabyte of {foreach} | 35.8 s | 0.34 s |
A page with {extends} and twenty {include}, auto_reload on | 25.3 us | 7.6 us |
The same without auto_reload | 7.2 us | 4.5 us |
A thousand {include} with a static name | 385 us | 235 us |
| A thousand-row table | 454 us | 422 us |
The first row is not an optimisation but a fixed complexity class: closing a block tag copied the whole accumulated body, so time grew with the square. The generated code is byte-for-byte the same.
The rest is ordinary hot-path work: an {include} with a static name is resolved once per render instead of on every iteration of an enclosing loop; realpath() left the cache-validity check; {foreach} binds its key and value to local references, so {$row.n} costs one hash lookup instead of two.
On artifact size: per-tag debug comments were about 45% of a generated file. They are now opt-in via debug_comments and by default cost neither disk nor opcache memory.
Stronger escaping turned out to be faster than weak escaping
ENT_QUOTES|ENT_SUBSTITUTE|ENT_HTML5 instead of ENT_COMPAT — 202 ns against 279. Hardening cost nothing here and paid for itself.
Security
What follows is not hypothetical risk but things upstream declared and did not do.
{$.php} and {$.call} were not checked at all. They reached call_user_func_array() without a single check: the option forbidding PHP calls existed and the code never asked for it. Both the ban and the native-function whitelist are now honoured.
The accessor ban was declared and never checked — the option was accepted, mapped into settings, and affected nothing.
Two call filters blocked everything. The conditions combined with AND instead of OR, so adding a second rule forbade every callback, including the ones the first rule allowed.
{strip} turned auto-escaping off for good. The tag wrote a different option from the one it was given, so after the first {strip} the page rendered unescaped for the rest of the request.
Escaping let the apostrophe through and swallowed data. ENT_COMPAT left ' raw, and on malformed UTF-8 it returned an empty string — the value simply vanished from the page.
escape:'js' did not close the way out of the tag. The strategy is hex-based now, and a </script> inside a string no longer closes the element. An unknown strategy throws instead of pretending to work.
Then the file side: the compile directory no longer falls back to /tmp, a world-writable directory is refused, artifacts are written 0640. Cache names are sha256 — crc32 collided, meaning one template could be served another's compiled code. The provider checks path containment separator-aware, rejects NUL, and does not delete through symlinks.
What came back, and what is new
{for} works again. The PHP 8 migration upstream deleted this tag's compiler methods while leaving its registration, so a documented tag fatalled from 3.0.0 on.
{use} and {paste} emitted syntactically invalid PHP — and cached it, so the error outlived an edit to the template.
addFunctionSmart() accepts any callable. Only strings used to work: the parser called strpos() on the callback, so a closure, an array callable or an invokable object was a TypeError.
Only one option is genuinely new — debug_comments, which turns per-tag debugging on when you want it. disable_php_calls and disable_accessor existed before the fork but were never checked; now they are.
Removed
auto_trim / pbFenom::AUTO_TRIM — reserved and inert since 2013. Passing it now throws Undefined option 'auto_trim' instead of being silently accepted and ignored. {autotrim} and the :trim / :ltrim / :rtrim tag options were documented for a decade and never implemented; their page is gone.
Version
pbFenom::VERSION is 3.1, tracking the upstream generation the fork branched from. The package version is separate (pageblocks/fenom 1.x).
Where it is configured
PageBlocks builds the engine in View::init(): cache directory, auto_reload from the pageblocks_fenom_auto_reload setting, and the template providers — see Settings.