Handlebars 

Extension key

handlebars

Package name

cpsit/typo3-handlebars

Version

main

Language

en

Author

coding. powerful. systems. CPS GmbH

License

This extension documentation is published under the CC BY-NC-SA 4.0 (Creative Commons) license.


An extension for TYPO3 CMS that provides an entire rendering environment for Handlebars templates. It is seamlessly integrated into TYPO3 and offers extensive configuration options to get all the power out of your templates. To meet everyone's needs, it is easily extensible using TYPO3 on-board tools.


Introduction 

A quick overview about the main features provided by this extension.

Installation 

Instructions on how to install this extension, and which TYPO3 and PHP versions are currently supported.

Configuration 

Learn how to configure the extension in various ways. This includes extension configuration, site configuration and TypoScript configuration.

Usage 

This section describes how to use this extension in various ways, and which additional components exist.

Developer corner 

A quick overview about all relevant classes provided by this extension.

Guides 

Task-oriented guides for common integration and migration scenarios, including a step-by-step path for migrating from Fluid to Handlebars.

Migration 

Required migration steps when upgrading the extension to a new major version.

Introduction 

What does it do? 

The extension provides a full rendering environment for Handlebars templates within TYPO3 CMS. All core features of Handlebars.js are supported by the usage of the third-party library PHP Handlebars.

Its main use is to seamlessly integrate Handlebars templates into TYPO3 without the need to modify these templates again for output in TYPO3.

Features 

  • Templating engine: Full Handlebars rendering environment for TYPO3
  • Custom Helpers: Custom helpers with auto-registration via PHP attributes
  • Extbase support: Controller-based rendering via HandlebarsView
  • Events: PSR-14 hooks into the full rendering pipeline
  • DI integration: Built on dependency injection for better performance and maintainability
  • Caching: Integration with TYPO3's cache framework for compiled templates
  • Extensibility: Easy to extend and customize
  • Compatibility: Compatible with TYPO3 13.4 LTS and 14.3 LTS

Support 

There are several ways to get support for this extension:

Security Policy 

Please read our security policy if you discover a security vulnerability in this extension.

License 

This extension is licensed under GNU General Public License 2.0 (or later).

Installation 

Requirements 

  • PHP 8.2 - 8.5
  • TYPO3 13.4 LTS - 14.3 LTS

Installation 

Require the extension via Composer (recommended):

composer require cpsit/typo3-handlebars
Copied!

Or download it from the TYPO3 extension repository.

Site sets 

The extension provides two site sets that can be included in the site configuration or any other site set.

  • cpsit/handlebars Handlebars base

    • Wires plugin.tx_handlebars.view.templateRootPaths and plugin.tx_handlebars.view.partialRootPaths from the site settings handlebars.view.templateRootPath and handlebars.view.partialRootPath .
    • Include this set for every site that renders Handlebars templates.
  • cpsit/handlebars-content-element Handlebars content elements

    • Sets lib.contentElement = HANDLEBARSTEMPLATE , replacing the default Fluid base object used by EXT:fluid_styled_content.
    • Include this set when all content elements should use Handlebars rendering by default.
config/sites/<my-site>/config.yaml
dependencies:
  - cpsit/handlebars
  - cpsit/handlebars-content-element
Copied!

Template paths 

Template and partial root paths are collected from various sources, each with a distinct priority. Higher-priority sources win over lower-priority ones. Within a single source, higher numeric keys override lower ones.

Priority order 

Source Priority Cacheable
Per-content-object TypoScript 100 No
plugin.tx_handlebars.view 50 Yes
Service container (e.g. Services.yaml) 0 Yes

Per-content-object (priority 100) 

Template and partial root paths can be set directly inside a HANDLEBARSTEMPLATE content object. These paths apply only to that specific rendering, including any nested partial lookups triggered by it.

tt_content.textmedia = HANDLEBARSTEMPLATE
tt_content.textmedia {
    templateRootPaths {
        10 = EXT:my_extension/Resources/Private/Templates
    }
    partialRootPaths {
        10 = EXT:my_extension/Resources/Private/Partials
    }
}
Copied!

TypoScript (priority 50) 

Global paths for all renderings on the current page can be configured under plugin.tx_handlebars.view :

plugin.tx_handlebars {
    view {
        templateRootPaths {
            10 = EXT:my_extension/Resources/Private/Templates
        }
        partialRootPaths {
            10 = EXT:my_extension/Resources/Private/Partials
        }
    }
}
Copied!

The cpsit/handlebars site set also populates these paths from the site settings {$handlebars.view.templateRootPath} and {$handlebars.view.partialRootPath} .

Service container (priority 0) 

The lowest-priority source is the service container. Paths registered here apply instance-wide, regardless of the current page or content object, and serve as the global fallback.

Configuration/Services.yaml
handlebars:
  view:
    templateRootPaths:
      10: EXT:my_extension/Resources/Private/Templates
    partialRootPaths:
      10: EXT:my_extension/Resources/Private/Partials
Copied!

The HandlebarsExtension DI extension merges all paths declared this way into the container parameters %handlebars.templateRootPaths% and %handlebars.partialRootPaths% .

Template name resolution 

Once the root paths are collected, the active TemplateResolver turns a template or partial name into an absolute file path. The default resolver supports two addressing styles: directory-relative paths and flat @-prefixed names.

Flat template names 

The default template resolver lets you reference any template or partial by its bare filename rather than a directory-relative path. Prefix the name with @ to use this addressing:

tt_content.tx_myext_teaser = HANDLEBARSTEMPLATE
tt_content.tx_myext_teaser {
    templateName = @teaser
}
Copied!
{{> @card}}
Copied!

The resolver scans all configured root paths and finds the file by name regardless of its subdirectory. If the same filename exists in multiple root paths, the higher-priority root path wins. This matches the Fractal template resolution convention, making it straightforward to use a Fractal component library as the template source.

Appending --<variant> selects a named variant and falls back to the base name automatically if no dedicated file exists:

{{> @card--highlighted}}   {{!-- falls back to @card if not found --}}
Copied!

Names without the @ prefix are resolved as directory-relative paths in the usual way.

Variables 

Template variables are available at two scopes: globally for every rendering, and locally for a single content object rendering.

Global variables 

Global variables are merged into every template rendering automatically. They can be defined through TypoScript or the service container.

Via TypoScript 

Use plugin.tx_handlebars.variables to define variables available on every page where the TypoScript is active. This is best suited for dynamic variables that depend on the current request context, since the underlying provider can only resolve them once a request is available (see the tip below):

plugin.tx_handlebars {
    variables {
        pageTitle = TEXT
        pageTitle.data = page:title

        campaign = TEXT
        campaign.field = campaign
    }
}
Copied!

Via service container 

Variables can also be defined instance-wide through Services.yaml. These apply regardless of TypoScript configuration:

Configuration/Services.yaml
handlebars:
  variables:
    publicPath: /assets
    apiEndpoint: https://api.example.com
Copied!

Per-rendering variables 

Variables scoped to a single rendering are declared in the variables property of a HANDLEBARSTEMPLATE content object. Each entry is processed as a standard content object against the current record's data:

tt_content.header = HANDLEBARSTEMPLATE
tt_content.header {
    templateName = Header

    variables {
        header = TEXT
        header.field = header

        subheader = TEXT
        subheader.field = subheader

        link = TEXT
        link.typolink.parameter.field = header_link
    }
}
Copied!

Entries with no sub-configuration are treated as simple variables and passed to the template as-is, without invoking ContentObjectRenderer :

variables {
    # Content object — field value is rendered via cObjGetSingle
    header = TEXT
    header.field = header

    # Simple variables — values are passed through directly
    cssClass = my-element
    theme = dark
}
Copied!

Two variables are always injected automatically and cannot be overridden (this reflects the same behavior as in FLUIDTEMPLATE ):

data
The full data array of the current content element record.
current
The value of the current field ( $cObj->currentValKey ).

Cache 

The extension registers a cache named handlebars that stores compiled Handlebars templates. The cache is registered automatically on extension activation; no manual setup is required.

The default backend is the TYPO3 database cache. To use a different backend, add an override to your extension's ext_localconf.php file:

ext_localconf.php
if (!isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['handlebars']['backend'])) {
    $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']['handlebars']['backend']
        = \TYPO3\CMS\Core\Cache\Backend\SimpleFileBackend::class;
}
Copied!

Extension configuration 

The extension provides configuration options via TYPO3's extension configuration. These are mapped onto typed configuration classes and are available for autowiring as \CPSIT\Typo3Handlebars\Configuration\HandlebarsConfiguration .

Properties


rendering.strictMode 

Type
boolean
Default
false
Description
Enables strict mode for the Handlebars renderer. In strict mode, the compiled template throws an exception as soon as it encounters a variable, property, or path that does not exist, instead of silently rendering an empty string. This is useful during development to catch typos in variable names and template paths early.

Quick start 

This page walks through a minimal working example: rendering the header CType with a Handlebars template.

  1. Include site sets

    The extension ships two site sets. Include them in your site's configuration via the site module or config/sites/<site>/sets.yaml.

    cpsit/handlebars (required)
    Wires plugin.tx_handlebars.view paths from the site settings handlebars.view.templateRootPath and handlebars.view.partialRootPath . Required for every site that renders Handlebars templates.
    cpsit/handlebars-content-element (optional)
    Sets lib.contentElement = HANDLEBARSTEMPLATE , replacing the default Fluid base object. Include this set when all content elements should use Handlebars rendering by default.
  2. Configure template paths

    Declare where your .hbs files are located. The simplest option is TypoScript:

    plugin.tx_handlebars {
        view {
            templateRootPaths {
                10 = EXT:my_sitepackage/Resources/Private/Templates/Handlebars
            }
            partialRootPaths {
                10 = EXT:my_sitepackage/Resources/Private/Partials/Handlebars
            }
        }
    }
    Copied!
  3. Create a Handlebars template

    Create the template file at the path declared above. The filename without the .hbs extension is used as the templateName :

    EXT:my_sitepackage/Resources/Private/Templates/Handlebars/Header.hbs
    <div class="ce-header">
        {{#if header}}
            <h1 class="ce-header__title">{{header}}</h1>
        {{/if}}
        {{#if subheader}}
            <p class="ce-header__subtitle">{{subheader}}</p>
        {{/if}}
    </div>
    Copied!
  4. Configure the content element

    Point the header CType at the template using a HANDLEBARSTEMPLATE content object:

    tt_content.header = HANDLEBARSTEMPLATE
    tt_content.header {
        templateName = Header
    
        variables {
            header = TEXT
            header.field = header
    
            subheader = TEXT
            subheader.field = subheader
        }
    }
    Copied!

    Each entry in variables is processed as a TYPO3 content object against the current content element record. The resulting values are passed to the template alongside the automatically injected data and current variables.

  5. Flush caches

    After editing TypoScript, flush the TYPO3 page cache. After editing Services.yaml, flush and rebuild the service container as well.

Next steps 

HANDLEBARSTEMPLATE content object 

HANDLEBARSTEMPLATE is a custom content object type provided by this extension. It compiles and renders a Handlebars template, resolving template paths, processing variables, and registering assets — all from TypoScript configuration.

tt_content.header = HANDLEBARSTEMPLATE
tt_content.header {
    templateName = Header
    templateRootPaths.20 = EXT:my_extension/Resources/Private/Templates
}
Copied!

templateName 

Type
string / stdWrap
Description
Name of the template to render. The value is resolved as a filename (without the .hbs extension) relative to the configured template root paths. Exactly one of templateName , template , or file must be set.
Example
templateName = Header

# With stdWrap
templateName.field = tx_myext_template_name
Copied!

template 

Type
string / stdWrap
Description
Inline Handlebars source used directly as the template. Useful for short or dynamically constructed templates. Cannot be used together with templateName or file .
Example
template = <h1>{{header}}</h1>
Copied!

file 

Type
string / stdWrap
Description
Absolute or EXT: -relative path to a Handlebars template file. Cannot be used together with templateName or template .
Example
file = EXT:my_extension/Resources/Private/Templates/Special.hbs
Copied!

templateRootPaths 

Type
array (numeric keys)
Description
Template root paths for this content object. These are added to the content object path provider with the highest priority (100), overriding any TypoScript or service container paths for this rendering. Higher numeric keys take precedence over lower ones.
Example
templateRootPaths {
    10 = EXT:my_extension/Resources/Private/Templates
    20 = EXT:my_other_extension/Resources/Private/Templates
}
Copied!

partialRootPaths 

Type
array (numeric keys)
Description
Partial root paths for this content object. Same priority and override rules as templateRootPaths .
Example
partialRootPaths {
    10 = EXT:my_extension/Resources/Private/Partials
}
Copied!

variables 

Type
array
Description

Variables passed to the template. Each entry is processed as a content object against the current content element's data record. Simple string values are passed through as-is; entries with a sub-array are rendered via ContentObjectRenderer::cObjGetSingle() .

Two variable names are reserved and always available automatically:

  • data — the full content element data array
  • current — the current field value
Example
variables {
    header = TEXT
    header.field = header

    bodytext = TEXT
    bodytext.field = bodytext
    bodytext.parseFunc < lib.parseFunc_RTE

    image = FILES
    image.references.fieldName = image
}
Copied!

settings 

Type
array
Description
Arbitrary key-value pairs passed to the template as the settings variable. Unlike variables , entries are not processed as content objects — values are used as plain strings.
Example
settings {
    showDate = 1
    dateFormat = d.m.Y
}
Copied!

In the template:

{{#if settings.showDate}}
    <time>{{formatDate date settings.dateFormat}}</time>
{{/if}}
Copied!

dataProcessing 

Type
array
Description

Standard data processors, executed after variables are resolved. Processors receive and return the $processedData array. Any key added by a processor is available as a template variable.

The extension provides three additional processors: process-variables , resolve-markers , and unflatten-variable-names .

Example
dataProcessing {
    10 = database-query
    10 {
        table = tx_myext_domain_model_item
        as = items
    }

    20 = process-variables
    20 {
        as = items
        merge = 1
        variables {
            label = TEXT
            label.field = title
        }
    }
}
Copied!

preProcessing 

Type
array
Description
Data source aware processors executed before variables are processed. These can read from multiple data sources (content element record, processed data, processor configuration) and modify the variable set before content object rendering begins.

postProcessing 

Type
array
Description
Data source aware processors executed after variables have been resolved and data processors have run, but before the template is rendered.

assets 

Type
array
Description
Registers JavaScript and CSS assets via TYPO3's AssetCollector API. Supports four sub-keys: javaScript , inlineJavaScript , css , inlineCss .
Example
assets {
    javaScript {
        my-ext-app {
            source = EXT:my_extension/Resources/Public/JavaScript/app.js
            attributes.defer = 1
            options.useNonce = 1
        }
    }
    css {
        my-ext-styles {
            source = EXT:my_extension/Resources/Public/Css/styles.css
        }
    }
}
Copied!

headerAssets 

Type
content object
Description
Adds arbitrary markup to the page <head> . The value is evaluated as a content object and the result is passed to PageRenderer::addHeaderData() .
Example
headerAssets = TEXT
headerAssets.value = <link rel="stylesheet" href="/assets/styles.css">
Copied!

footerAssets 

Type
content object
Description
Adds arbitrary markup before the closing </body> tag. The value is evaluated as a content object and the result is passed to PageRenderer::addFooterData() .
Example
footerAssets = TEXT
footerAssets.value = <script src="/assets/app.js"></script>
Copied!

stdWrap 

Type
stdWrap
Description
Standard TYPO3 stdWrap processing applied to the final rendered output.
Example
stdWrap.wrap = <div class="handlebars-content">|</div>
Copied!

Extbase plugins 

HandlebarsViewFactory integrates Handlebars rendering into the Extbase MVC stack. It implements \TYPO3\CMS\Core\View\ViewFactoryInterface and is wired globally, so every Extbase controller that goes through the standard view factory mechanism automatically benefits from it without any code changes.

When the factory detects an Extbase request it reads the handlebars key from the plugin's TypoScript configuration and returns a HandlebarsView . If no handlebars key is present and the controller does not extend HandlebarsController , the factory falls back to the Fluid view.

TypoScript configuration 

Per-plugin Handlebars configuration lives under the handlebars key in the plugin's TypoScript configuration:

plugin.tx_myextension_myplugin {
    handlebars {
        default {
            templateRootPaths.10 = EXT:my_extension/Resources/Private/Templates/Handlebars
            partialRootPaths.10 = EXT:my_extension/Resources/Private/Partials/Handlebars
        }
    }
}
Copied!

Resolution keys 

The handlebars array supports four keys, resolved and merged from least to most specific so that narrower entries override broader ones:

Key Applies to
default All controllers in the plugin
<ControllerAlias> One controller, all actions
<ControllerAlias>::<actionName> One controller, one action
<ControllerFQCN> Controller matched by fully-qualified class name
plugin.tx_myextension_myplugin {
    handlebars {
        default {
            templateRootPaths.10 = EXT:my_extension/Resources/Private/Templates/Handlebars
            partialRootPaths.10 = EXT:my_extension/Resources/Private/Partials/Handlebars
        }
        Blog {
            templateRootPaths.20 = EXT:my_extension/Resources/Private/Templates/Blog
        }
        Blog::list {
            templateName = @blog-list
        }
    }
}
Copied!

The controller alias matches the value registered in ExtensionUtility::configurePlugin() . For the example above, BlogController would typically have alias Blog .

Properties 

Each resolution key accepts the same properties as a HANDLEBARSTEMPLATE content object:

Property Type Description
templateName string Template name or @-prefixed flat name. Defaults to <ControllerAlias>/<action> .
format string File extension. Defaults to hbs.
templateRootPaths array Additional template root paths.
partialRootPaths array Additional partial root paths.
variables array Extra variables passed to the template.

Default template name 

When no templateName is configured, the factory derives one automatically from the controller alias and action name:

<ControllerAlias>/<actionName>
Copied!

For BlogController::listAction() with alias Blog this resolves to Blog/list.hbs under the configured template root paths.

HandlebarsController 

For controllers you own, extend \CPSIT\Typo3Handlebars\Controller\HandlebarsController instead of ActionController . This guarantees Handlebars rendering even when no handlebars TypoScript key is present — the factory always returns a HandlebarsView for these controllers:

EXT:my_extension/Classes/Controller/BlogController.php
namespace Vendor\MyExtension\Controller;

use CPSIT\Typo3Handlebars\Controller\HandlebarsController;
use Psr\Http\Message\ResponseInterface;

final class BlogController extends HandlebarsController
{
    public function listAction(): ResponseInterface
    {
        $this->view->assign('posts', $this->postRepository->findAll());

        return $this->htmlResponse($this->renderView());
    }
}
Copied!

Fluid fallback 

During an incremental migration you may need to keep some actions on Fluid while others are already on Handlebars. Call delegateRendering() on the view to hand off to the underlying Fluid view for that action:

public function legacyAction(): ResponseInterface
{
    $this->view->assign('items', $this->repository->findAll());

    $content = $this->view instanceof HandlebarsView
        ? $this->view->delegateRendering()
        : $this->view->render();

    return $this->htmlResponse((string)$content);
}
Copied!

iterable-to-array 

Class: \CPSIT\Typo3Handlebars\DataProcessing\IterableToArrayProcessor

Converts an iterable value — an Extbase QueryResultInterface as returned by a repository, an ObjectStorage , a plain Iterator , or a Generator — into a plain array. Templates and other data processors generally expect array data, so this processor is the usual bridge between a repository result and the rest of the dataProcessing chain.

Data sources 

iterable is resolved exactly like dataSource on process-each — see Resolving a data payload (dataSource / data) for the full syntax, including what happens when multiple references are configured. It just uses a processor-specific option name instead of the generic dataSource . iterable.current uses the content object's current value instead (see Using the content object's current value).

This is what allows iterable to refer to a value placed into processedData by a preceding processor's as option, or to a variable an Extbase controller assigned directly to the view (Extbase-assigned view variables end up in processedData too, under the assigned key).

Usage 

EXT:my_extension/Classes/Controller/NewsController.php
final class NewsController extends HandlebarsController
{
    public function listAction(): ResponseInterface
    {
        $this->view->assign('news', $this->newsRepository->findAll());

        return $this->htmlResponse($this->renderView());
    }
}
Copied!
plugin.tx_myextension_news.handlebars {
    News::list {
        dataProcessing {
            10 = iterable-to-array
            10 {
                iterable = processedData:news
                as = newsItems
            }
        }
    }
}
Copied!

Processing individual items 

Each converted item is exposed to a nested dataProcessing chain as currentValue , reachable as contentObjectConfiguration:currentValue (see Data sources). This only happens when a nested chain is actually configured, so plain conversions are left untouched:

dataProcessing {
    10 = iterable-to-array
    10 {
        iterable = processedData:news
        as = newsItems

        dataProcessing {
            10 = object-access
            10 {
                object = contentObjectConfiguration:currentValue
                path = title
                as = title
            }
        }
    }
}
Copied!

Properties 

iterable
Data source reference(s) the value to convert is read from (see Data sources). Required.
as
Target key in the processed data array the resulting array is stored under. Default: result .
preserveKeys
Boolean. When 1 , the original keys of the iterable (e.g. array keys or Generator keys) are preserved. When 0 , the result is reindexed as a plain list. Default: 0 .
dataProcessing
Nested data processors, run for each item of the converted array (see Processing individual items).

If iterable is not configured, or the resolved value is not iterable, a warning is logged and the processed data is returned unchanged.

media 

Class: \CPSIT\Typo3Handlebars\DataProcessing\MediaProcessor

Resolves a file or file reference and runs it through a matching media processor — a pluggable component that turns a resource into whatever shape a template needs (for example, an image with responsive source sets). The extension does not ship any media processors itself; see MediaProcessor for how to register your own.

Data sources 

file is resolved exactly like object on object-access — see Resolving a data payload (dataSource / data) for the full syntax. It just uses a processor-specific option name instead of the generic dataSource .

Usage 

tt_content.textpic = HANDLEBARSTEMPLATE
tt_content.textpic {
    templateName = @textpic

    dataProcessing {
        10 = files
        10 {
            references.fieldName = image
            as = files
        }

        20 = media
        20 {
            file = processedData:files.0
            as = image

            config {
                image {
                    # options for a registered "image" media processor
                }
            }
        }
    }
}
Copied!

The first file resolved by the core files processor is passed to media , which picks whichever registered media processor's supports() method matches it first — here, a media processor registered under the name image — and stores its result under image .

Properties 

file
Data source reference the resource is read from (see Data sources). Required.
as
Target key in the processed data array the media processor's result is stored under. Default: result .
config.<name>
Configuration passed to the media processor registered under <name> . Only applied if that processor actually matches the resolved resource.

If file cannot be resolved, or no registered media processor supports the resolved resource, a warning is logged and the processed data is returned unchanged.

object-access 

Class: \CPSIT\Typo3Handlebars\DataProcessing\ObjecAccessProcessor

Resolves a property (or nested property path) from an object and stores the resulting value in the processed data. This is useful for pulling individual properties out of an Extbase domain object (or any other object) that was placed into the data processing chain by a preceding processor or controller, without having to expose the whole object to the template.

Data sources 

object is resolved exactly like dataSource on process-each — see Resolving a data payload (dataSource / data) for the full syntax, including what happens when multiple references are configured. It just uses a processor-specific option name instead of the generic dataSource . object.current uses the content object's current value instead (see Using the content object's current value).

This is what allows object to refer to a value placed into processedData by a preceding processor's as option, or to a variable an Extbase controller assigned directly to the view (Extbase-assigned view variables end up in processedData too, under the assigned key).

Usage 

dataProcessing also runs for Extbase plugins whose controller extends HandlebarsController, since HandlebarsView renders through the same HANDLEBARSTEMPLATE content object under the hood. An action that assigns a domain object to the view makes that object available to object-access under the assigned key:

EXT:my_extension/Classes/Controller/BlogController.php
final class BlogController extends HandlebarsController
{
    public function showAction(Post $post): ResponseInterface
    {
        $this->view->assign('post', $post);

        return $this->htmlResponse($this->renderView());
    }
}
Copied!
plugin.tx_myextension_blog.handlebars {
    Blog::show {
        dataProcessing {
            # Pull the related category's title off the assigned "post" object
            10 = object-access
            10 {
                object = processedData:post
                path = category.title
                as = categoryTitle
            }
        }
    }
}
Copied!

Properties 

object
Data source reference(s) the source object is read from (see Data sources). Required.
path
Property path passed to TYPO3Fluid\Fluid\Core\Variables\StandardVariableProvider::getByPath() . Supports plain property names as well as dot-separated nested paths (e.g. category.title ). Required.
as
Target key in the processed data array the resolved value is stored under. Default: result .

If object is not configured, or the resolved value is not an object, or path is missing, or path is not a gettable property on the resolved object, a warning is logged and the processed data is returned unchanged.

When the resolved value is itself an array, it is run through the standard TYPO3 dataProcessing chain configured on this processor, just like TYPO3's own menu or database-query processors do for their items.

process-each 

Class: \CPSIT\Typo3Handlebars\DataProcessing\ProcessEachProcessor

Iterates over an array (or other iterable) and, for each item, evaluates a variables configuration block and/or runs a nested dataProcessing chain against that single item. This is the usual way to enrich or reshape every entry of an array produced by an earlier processor — for example, running object-access against each file reference returned by TYPO3's core files processor.

Resolving the items to iterate 

The value to iterate over is resolved in the following order:

  1. dataSource , if configured — see Resolving a data payload (dataSource / data) for how it is resolved, including what happens when multiple references are configured, and Using the content object's current value for dataSource.current .
  2. Otherwise, an inline data array configured directly on this processor.
  3. Otherwise, a data key already present in processedData . This makes process-each usable as a nested processor inside iterable-to-array or object-access — both of which wrap each item as data for their own nested dataProcessing chain — without having to repeat dataSource explicitly.

If none of these yield an iterable value, the processed data is returned unchanged.

Usage 

tt_content.textpic = HANDLEBARSTEMPLATE
tt_content.textpic {
    templateName = @textpic

    dataProcessing {
        10 = files
        10 {
            references.fieldName = image
            as = files
        }

        20 = process-each
        20 {
            dataSource = processedData:files
            as = processedFiles

            dataProcessing {
                10 = object-access
                10 {
                    object = contentObjectConfiguration:currentValue
                    path = publicUrl
                    as = url
                }

                20 = object-access
                20 {
                    object = contentObjectConfiguration:currentValue
                    path = fileType
                    as = type
                }
            }
        }
    }
}
Copied!

Each file reference resolved by the core files processor is made available to the nested dataProcessing chain as currentValue , reachable as contentObjectConfiguration:currentValue (see Data sources), so object-access can pull individual properties off it. The per-item results are collected — keyed by the original array keys — under processedFiles .

Per-item processing 

Two mechanisms are available for each item, and can be combined:

variables

A variables block, processed exactly like the top-level variables of HANDLEBARSTEMPLATE , but with the content object's current value set to the item. Use current = 1 (instead of field ) to reference the item itself:

20 = process-each
20 {
    dataSource = processedData:tags
    as = processedTags

    variables {
        label = TEXT
        label.current = 1
        label.case = upper
    }
}
Copied!

data and current are reserved and cannot be used as variable names here.

dataProcessing
A standard nested dataProcessing chain (see Usage), with the item exposed as currentValue . Its result is merged with — and overrides — whatever variables produced for the same item.

Properties 

data
Inline data to iterate over, used if dataSource is not configured (see Resolving the items to iterate).
dataSource
Data source(s) to read the iterable from (see Resolving the items to iterate). dataSource.current uses the content object's current value instead (see Using the content object's current value).
as
Target key in the processed data array the resulting (keyed) array is stored under. Default: result .
variables
Per-item variables block (see Per-item processing).
dataProcessing
Per-item nested data processors (see Per-item processing).

process-variables 

Class: \CPSIT\Typo3Handlebars\DataProcessing\ProcessVariablesProcessor

Processes a variables configuration block — exactly like the top-level variables of HANDLEBARSTEMPLATE — within a data processor chain. This is most useful when combined with other processors such as database-query , allowing per-record variable processing.

Data sources 

as , merge and variables are read from this processor's own configuration block ( processorConfiguration ) only. table is also read from there, but falls back to a same-named key already present in processedData — this is why table , once set by an outer processor (e.g. database-query ), can be picked up by a nested process-variables without being repeated explicitly. See Data sources for what these two sources contain.

The preProcessing and postProcessing hooks receive the full DataSourceCollection , so custom DataSourceAwareProcessor implementations have access to all four sources (see DataSourceAwareProcessor).

Choosing which record to process 

By default, the field option of a variables entry resolves against the current content element's own record. Configure dataSource (or an inline data array) to process a different record or array instead — see Resolving a data payload (dataSource / data) for how it is resolved, and Using the content object's current value for dataSource.current . If the resolved payload is not an array, it is ignored and the current record's own field values are used instead.

Standalone usage 

tt_content.my_element = HANDLEBARSTEMPLATE
tt_content.my_element {
    templateName = MyElement

    dataProcessing {
        10 = process-variables
        10 {
            variables {
                header = TEXT
                header.field = header

                teaser = TEXT
                teaser.field = bodytext
                teaser.parseFunc < lib.parseFunc_RTE
            }
        }
    }
}
Copied!

Nested inside another processor 

dataProcessing {
    10 = database-query
    10 {
        table = tx_myext_domain_model_item
        as = items

        dataProcessing {
            10 = process-variables
            10 {
                table = tx_myext_domain_model_item
                as = item
                variables {
                    title = TEXT
                    title.field = title

                    body = TEXT
                    body.field = bodytext
                    body.parseFunc < lib.parseFunc_RTE
                }
            }
        }
    }
}
Copied!

Properties 

variables
Variables to process. Same syntax as the top-level variables in HANDLEBARSTEMPLATE .
table
Database table of the record to use as the data source for field lookups. Defaults to the current content element table.
data
Inline data used as the field-lookup source for variables , if dataSource is not configured (see Choosing which record to process).
dataSource
Data source(s) to use as the field-lookup source for variables , instead of the current record (see Choosing which record to process).
as
Target key in the processed data array. When set, the processed variables are stored under this key. When omitted, the processed variables replace (or merge into) the root of the processed data.
merge
Boolean. When 1 and as is omitted, the processed variables are merged into the existing processed data rather than replacing it. When as is set and the key already holds an array, the processed variables are merged into that array. Default: 0 .
if
Standard TypoScript if condition. When the condition evaluates to false, the processor is skipped and the processed data is returned unchanged.
preProcessing
Data source aware processors run before variables are processed.
postProcessing
Data source aware processors run after variables are processed.

resolve-markers 

Class: \CPSIT\Typo3Handlebars\DataProcessing\ResolveMarkersProcessor

Replaces marker-style keys (e.g., ###NAV_ITEMS### ) in the processed data with the values stored under those keys. The typical pattern is to use a marker as a named placeholder early in the chain — either as the as target of a preceding processor or directly in a variables entry — and then resolve all markers to clean variable names in a final step. This keeps intermediate processors decoupled from the variable names the template expects.

tt_content.my_element = HANDLEBARSTEMPLATE
tt_content.my_element {
    templateName = MyElement

    dataProcessing {
        # Declare the expected output slots early using markers — these
        # names will become the final template variables
        10 = menu
        10 {
            as = ###mainNavigation###
            levels = 2
        }

        20 = menu
        20 {
            as = ###footerLinks###
            special = directory
            special.value = 42
        }

        # Resolve all markers to clean variable names in one final step
        90 = resolve-markers
        90.removeNonMatchingMarkers = 1
    }
}
Copied!

After processing, the template receives mainNavigation and footerLinks as clean variable names. The markers at the top of the chain serve as upfront documentation of what the template expects, while the processors that follow fill those slots independently.

Properties 

pattern
Regular expression used to identify marker keys. The first capture group becomes the resolved variable name. Default: ###(.*?)###
removeNonMatchingMarkers
Boolean. When 1 , variable keys that still match the marker pattern after resolution (i.e., no value was found for them) are removed from the processed data. Default: 0 .

unflatten-variable-names 

Class: \CPSIT\Typo3Handlebars\DataProcessing\UnflattenVariableNamesProcessor

Converts dot-separated flat variable names into nested arrays. This is useful when other processors set their as key to a dotted path, representing the intended position in a nested data structure.

dataProcessing {
    10 = menu
    10 {
        as = page.nav.mainMenu
    }

    20 = menu
    20 {
        as = page.nav.footerLinks
        special = directory
        special.value = 42
    }

    90 = unflatten-variable-names
}
Copied!

After processing, the template receives a nested page object:

{{#each page.nav.mainMenu}}
    <a href="{{link}}">{{title}}</a>
{{/each}}
Copied!

The processor has no configuration properties and operates on the entire processed data array.

Data sources 

Several data processors resolve part of their configuration, or their whole input payload, from one or more data sources made available during TypoScript dataProcessing . This page describes the concept once; see iterable-to-array, object-access, process-each and process-variables for how each processor applies it.

Overview 

Data source identifier Contains
processorConfiguration This processor's own config block
processedData Accumulated output from previous processors
contentObjectRenderer Current record's field values
contentObjectConfiguration Top-level HANDLEBARSTEMPLATE config

Not every source necessarily holds what its name suggests. A processor invoked through TYPO3's dataProcessing chain always receives a contentObjectConfiguration , but once it is nested inside another processor's own dataProcessing , that value is no longer the top-level HANDLEBARSTEMPLATE configuration — it is whatever the parent processor forwards instead (its own config block, for example). The collection built for HANDLEBARSTEMPLATE 's own preProcessing / postProcessing hooks, which run before dataProcessing itself, sets only contentObjectRenderer and processorConfiguration ; contentObjectConfiguration and processedData are genuinely absent there.

Priority order 

When a lookup is not restricted to a specific source, all sources that are actually available are searched in priority order, highest first:

  1. processorConfiguration
  2. processedData
  3. contentObjectRenderer
  4. contentObjectConfiguration

The first source that has the requested key wins; sources are never merged for this kind of lookup. This is why an option like table , set by an outer processor, can be picked up by a nested processor without being repeated explicitly — as long as the nested processor actually queries that source (some processors restrict a given option to a specific source, or a specific subset, rather than searching all four).

Reaching into nested keys 

A lookup key may use / to reach into a nested array within a single data source, e.g. some/nested/key . Each segment is looked up literally, one level at a time; the lookup fails (and any configured default value is used) as soon as one segment does not exist.

A key is otherwise always matched literally, dots and all. This matters for raw TypoScript arrays, which store sub-properties of a key under that same key with a literal trailing dot appended (e.g. dataProcessing. for the contents of a dataProcessing { ... } block) — such a key is looked up as-is and is not itself treated as a path.

Resolving a data payload (dataSource / data) 

process-each and process-variables accept a dataSource (or inline data ) option to pick the actual payload they operate on, independently of whatever their own configuration otherwise resolves from the four sources above. iterable-to-array and object-access use the exact same mechanism under a processor-specific option name — iterable and object respectively — instead of the generic dataSource .

dataSource (or the processor-specific option name)

One or more data source references, each optionally scoped to a sub-path with a colon (e.g. processedData:files ). The sub-path itself may use / to reach further into a nested key (e.g. processedData:files/0 ) — see Reaching into nested keys. A single reference is resolved and used as-is, whatever its type — it is not coerced into an array.

Multiple references, configured as a TypoScript array with numeric keys, are resolved in ascending key order:

  • If every reference resolves to an array, they are merged, with later references overriding earlier ones on key conflicts.
  • If any reference does not resolve to an array, the last resolved reference wins outright — all earlier references, including any that were arrays, are discarded rather than partially merged.

A warning is logged, and the payload cannot be resolved, if the option is empty, references an unsupported data source identifier, a data source that is missing in the current context, or a sub-path that does not exist within a data source. Note that an unconfigured option (rather than one configured with an invalid value) is not itself an error — see the fallback below and each processor's own documentation for what happens next.

data
Inline data, used as a fallback if the option above is not configured at all. This fallback always uses the fixed key data ( data. for an inline array in processorConfiguration , or a plain data key already present in processedData ), regardless of what the primary option is called for a given processor.

See each processor's own documentation for further, processor-specific fallbacks once neither option yields a value.

Using the content object's current value 

Setting current = 1 as a sub-property of dataSource (or the processor-specific option name) bypasses the resolution described above entirely and uses the content object's current value — see ContentObjectRenderer::getCurrentVal() — as the payload instead:

dataSource {
    current = 1
}
Copied!

This is most useful for a nested processor that operates directly on the value a parent process-each set as current for the item being processed, without having to route that value through processedData or contentObjectConfiguration:currentValue first.

Custom helpers 

Handlebars helpers bring custom PHP logic into templates. The extension's Helper interface defines a single method:

public function render(\DevTheorem\Handlebars\HelperOptions $options): mixed;
Copied!

Named arguments passed from the template (e.g., {{greet name="Alice"}}) are available as $options->hash['name'] . Positional arguments (e.g., {{greet "Alice"}}) must be declared as additional parameters in the method signature after $options :

public function render(HelperOptions $options, ?string $name = null): mixed;
Copied!

The RenderingContext can also be injected by type-hint — declare it anywhere in the method signature before positional arguments and it is provided automatically. It gives access to the current PSR-7 request ( $context->getRequest() ) and the full set of template variables ( $context->getVariables() ):

public function render(HelperOptions $options, ?RenderingContext $context = null): mixed;
Copied!

The current template scope is accessible via $options->scope , and block helpers can call $options->fn() and $options->inverse() to render their inner blocks.

Implement a helper 

Implement the \CPSIT\Typo3Handlebars\Renderer\Helper\Helper interface and place the #[AsHelper] attribute on the class. Because the class implements the Helper interface, the attribute automatically resolves to the render method:

EXT:my_extension/Classes/Renderer/Helper/GreetHelper.php
namespace Vendor\Extension\Renderer\Helper;

use CPSIT\Typo3Handlebars\Attribute\AsHelper;
use CPSIT\Typo3Handlebars\Renderer\Helper\Helper;
use DevTheorem\Handlebars\HelperOptions;

#[AsHelper('greet')]
final readonly class GreetHelper implements Helper
{
    public function render(HelperOptions $options): mixed
    {
        return sprintf('Hello, %s!', $options->hash['name'] ?? 'World');
    }
}
Copied!

The attribute can also be placed directly on a method, in which case the method name is inferred automatically — no method parameter needed. Dependency injection works normally for all #[AsHelper] -annotated classes:

EXT:my_extension/Classes/Renderer/Helper/GreetHelper.php
use CPSIT\Typo3Handlebars\Attribute\AsHelper;
use CPSIT\Typo3Handlebars\Renderer\Helper\Helper;
use DevTheorem\Handlebars\HelperOptions;
use Vendor\Extension\Domain\Model\Person;
use Vendor\Extension\Domain\Repository\PersonRepository;

#[AsHelper('greet')]
final readonly class GreetHelper implements Helper
{
    public function __construct(
        private PersonRepository $repository,
    ) {}

    public function render(HelperOptions $options): mixed
    {
        return sprintf('Hello, %s!', $options->hash['name'] ?? 'World');
    }

    #[AsHelper('greetAll')]
    public function greetAll(HelperOptions $options): mixed
    {
        return implode(PHP_EOL, array_map(
            static fn(Person $person) => sprintf('Hello, %s!', $person->getName()),
            $this->repository->findAll(),
        ));
    }
}
Copied!

Use the helper in a template 

Reference the helper by its identifier:

{{greet name="Alice"}}

{{greetAll}}
Copied!

Alternative: Register via Services.yaml 

If you cannot use the attribute (e.g., for a third-party class), register the helper explicitly in Services.yaml. Both identifier and method are required:

Configuration/Services.yaml
services:
  Vendor\Extension\Renderer\Helper\GreetHelper:
    tags:
      - name: handlebars.helper
        identifier: 'greetAll'
        method: 'greetAll'
Copied!

Events 

The extension dispatches three PSR-14 events during the rendering pipeline.

BeforeTemplateCompilationEvent 

Dispatched immediately before a template is compiled. The event provides read-only access to the rendering context and the renderer. Use it to inspect the context (e.g., to log which template is about to be rendered) or to trigger additional rendering via the renderer:

EXT:my_extension/Classes/EventListener/BeforeTemplateCompilationListener.php
namespace Vendor\Extension\EventListener;

use CPSIT\Typo3Handlebars\Event\BeforeTemplateCompilationEvent;

final readonly class BeforeTemplateCompilationListener
{
    public function __invoke(BeforeTemplateCompilationEvent $event): void
    {
        $context = $event->getContext();   // RenderingContext
        $renderer = $event->getRenderer(); // Renderer
    }
}
Copied!

BeforeRenderingEvent 

Dispatched after variables have been resolved and merged, immediately before the compiled template is executed. The full variable set can be read and modified:

EXT:my_extension/Classes/EventListener/BeforeRenderingListener.php
namespace Vendor\Extension\EventListener;

use CPSIT\Typo3Handlebars\Event\BeforeRenderingEvent;

final readonly class BeforeRenderingListener
{
    public function __invoke(BeforeRenderingEvent $event): void
    {
        // Add a variable
        $event->addVariable('timestamp', time());

        // Replace the entire variable set
        $variables = $event->getVariables();
        $variables['title'] = strtoupper($variables['title'] ?? '');
        $event->setVariables($variables);

        // Remove a variable
        $event->removeVariable('internalFlag');
    }
}
Copied!

Available methods:

  • getVariables() / setVariables(array $variables) — get or replace the full variable set
  • addVariable(string $name, mixed $value) — add or overwrite a single variable
  • removeVariable(string $name) — remove a variable by name
  • getContext() — the RenderingContext (read-only)
  • getRenderer() — the Renderer (read-only)

AfterRenderingEvent 

Dispatched after the template has been fully rendered. The rendered HTML string can be read and replaced:

EXT:my_extension/Classes/EventListener/AfterRenderingListener.php
namespace Vendor\Extension\EventListener;

use CPSIT\Typo3Handlebars\Event\AfterRenderingEvent;

final readonly class AfterRenderingListener
{
    public function __invoke(AfterRenderingEvent $event): void
    {
        $content = $event->getContent();
        $event->setContent(trim($content));
    }
}
Copied!

Available methods:

  • getContent() / setContent(string $content) — get or replace the rendered output
  • getContext() — the RenderingContext (read-only)
  • getRenderer() — the Renderer (read-only)

Asset management 

The Handlebars extension integrates with TYPO3's Asset collector to manage JavaScript and CSS assets in your frontend rendering. Assets are registered directly through the assets configuration of a HANDLEBARSTEMPLATE content object.

Asset Types 

The AssetCollector API supports four distinct asset types, all fully supported by this extension:

  1. External JavaScript files — link to external .js files
  2. Inline JavaScript code — embed JavaScript directly in the page
  3. External CSS files — link to external .css files
  4. Inline CSS code — embed styles directly in the page

JavaScript files 

Register external JavaScript files using the javaScript configuration:

10 = HANDLEBARSTEMPLATE
10 {
    templateName = MyTemplate

    assets {
        javaScript {
            my-app-script {
                source = EXT:myext/Resources/Public/JavaScript/app.js
                attributes {
                    async = 1
                    defer = 1
                    crossorigin = anonymous
                }
                options {
                    priority = 1
                    csp = 1
                }
            }
        }
    }
}
Copied!

Inline JavaScript 

Add inline JavaScript code using inlineJavaScript :

assets {
    inlineJavaScript {
        my-inline-script {
            source = console.log('Hello from Handlebars'); initMyApp();
            attributes {
                type = module
            }
            options {
                priority = 1
            }
        }
    }
}
Copied!

CSS files 

Register external stylesheets using the css configuration:

assets {
    css {
        my-styles {
            source = EXT:myext/Resources/Public/Css/styles.css
            attributes {
                media = screen and (max-width: 768px)
            }
            options {
                priority = 1
            }
        }
    }
}
Copied!

Inline CSS 

Add inline styles using inlineCss :

assets {
    inlineCss {
        critical-css {
            source = body { margin: 0; padding: 0; } .container { max-width: 1200px; }
        }
    }
}
Copied!

Configuration reference 

source (required) 

Type
string
Description

Asset source. For external files, use EXT: syntax or absolute paths. For inline assets, provide the code directly as a string value.

Example
# External JavaScript file
source = EXT:myext/Resources/Public/JavaScript/file.js

# External CSS file
source = EXT:myext/Resources/Public/Css/styles.css

# Inline JavaScript code
source = console.log('Hello');

# Inline CSS code
source = body { margin: 0; }
Copied!

attributes 

Type
array
Description
HTML attributes for the generated tag. Boolean attributes ( async , defer , disabled ) should be set to 1 to enable them.
JavaScript attributes
  • async (boolean): Load script asynchronously
  • defer (boolean): Defer script execution
  • nomodule (boolean): Fallback for older browsers
  • type (string): Script type (e.g., "module")
  • crossorigin (string): CORS setting (e.g., "anonymous")
  • integrity (string): Subresource Integrity hash
CSS attributes
  • media (string): Media query (e.g., "screen", "print")
  • disabled (boolean): Disable stylesheet
  • title (string): Stylesheet title
  • crossorigin (string): CORS setting
  • integrity (string): Subresource Integrity hash
Example
attributes {
    async = 1
    defer = 1
    type = module
    crossorigin = anonymous
    integrity = sha384-abc123def456
}
Copied!

options 

Type
array
Description
AssetCollector-specific options that control asset rendering behaviour.
Available options
  • priority (boolean): Render before other assets (default: 0)
  • csp (boolean): Add CSP nonce attribute (default: 0). Requires TYPO3 v14+.
  • useNonce (boolean): Add CSP nonce attribute (default: 0). Deprecated since TYPO3 v14 — use csp instead.
Example
options {
    priority = 1
    csp = 1
}
Copied!

Renderer 

Implement the \CPSIT\Typo3Handlebars\Renderer\Renderer interface to replace the entire rendering stack — for example to use a different template engine, add a pre-render transformation, or wrap the compiled output. The default implementation is \CPSIT\Typo3Handlebars\Renderer\HandlebarsRenderer .

interface Renderer
Fully qualified name
\CPSIT\Typo3Handlebars\Renderer\Renderer
renderTemplate ( RenderingContext $context)

Compile and render a template. The RenderingContext carries the template path or inline source, the current variable set, and the PSR-7 request.

param RenderingContext $context

The current rendering context.

returntype

string

renderPartial ( RenderingContext $context)

Compile and render a partial.

param RenderingContext $context

The current rendering context.

returntype

string

Wiring the implementation 

Register the custom renderer as the implementation of the Renderer interface in your extension's Services.yaml:

Configuration/Services.yaml
services:
  CPSIT\Typo3Handlebars\Renderer\Renderer:
    alias: Vendor\Extension\Renderer\MyRenderer
Copied!

TemplateResolver 

Implement the \CPSIT\Typo3Handlebars\Renderer\Template\TemplateResolver interface to change how template and partial names are resolved to absolute file paths — for example to support a different directory layout, an additional file extension, or a database-driven path lookup.

The extension ships two implementations: \CPSIT\Typo3Handlebars\Renderer\Template\FlatTemplateResolver (the default) and \CPSIT\Typo3Handlebars\Renderer\Template\HandlebarsTemplateResolver .

\CPSIT\Typo3Handlebars\Renderer\Template\BaseTemplateResolver implements supports() and provides protected helpers for normalizing root paths and resolving filenames with EXT: syntax. Extending it keeps implementations concise.

interface TemplateResolver
Fully qualified name
\CPSIT\Typo3Handlebars\Renderer\Template\TemplateResolver
supports ( string $fileExtension)

Return true if this resolver handles the given file extension.

param string $fileExtension

File extension without leading dot.

returntype

bool

resolveTemplatePath ( string $templatePath, ?string $format = null)

Resolve a template name or relative path to its absolute file path.

param string $templatePath

Template name or relative path.

param string|null $format

Optional file extension override.

returntype

string

resolvePartialPath ( string $partialPath, ?string $format = null)

Resolve a partial name or relative path to its absolute file path.

param string $partialPath

Partial name or relative path.

param string|null $format

Optional file extension override.

returntype

string

FlatTemplateResolver 

FlatTemplateResolver is the default implementation. It scans all configured root paths recursively and builds an in-memory map of every template file, keyed by its bare filename (without directory). A lookup therefore succeeds regardless of where in the directory tree the file lives.

Template and partial names must be prefixed with @ to trigger flat resolution. A name without the prefix is passed directly to HandlebarsTemplateResolver (see below).

Referencing a flat template in TypoScript
tt_content.tx_myext_teaser = HANDLEBARSTEMPLATE
tt_content.tx_myext_teaser {
    templateName = @teaser
}
Copied!
Referencing a flat partial in a Handlebars template
{{> @card}}
Copied!

Variant separator

Appending --<variant> to an @-prefixed name selects a variant of a component. If no file with that exact name exists, the resolver automatically falls back to the base name:

{{> @card--highlighted}}   {{!-- falls back to @card if not found --}}
Copied!

This convention follows Fractal's naming rules.

File precedence

When the same filename exists under multiple root paths, the higher-priority root path wins (see Template paths). Within a single root path, files are sorted by name and the first occurrence is used, matching Fractal's uniqueness guarantee.

HandlebarsTemplateResolver 

HandlebarsTemplateResolver resolves template and partial names as paths relative to the configured root paths. Given the name Blog/List, it searches each root path (highest priority first) for a matching file — for example Blog/List.hbs.

This resolver is used as the fallback inside FlatTemplateResolver for any name that does not start with @, so both resolution strategies are active at the same time.

Example implementation 

EXT:my_extension/Classes/Renderer/Template/MyTemplateResolver.php
namespace Vendor\Extension\Renderer\Template;

use CPSIT\Typo3Handlebars\Exception;
use CPSIT\Typo3Handlebars\Renderer\Template\BaseTemplateResolver;
use CPSIT\Typo3Handlebars\Renderer\Template\TemplatePaths;

final readonly class MyTemplateResolver extends BaseTemplateResolver
{
    public function __construct(
        private TemplatePaths $templatePaths,
    ) {}

    public function resolveTemplatePath(string $templatePath, ?string $format = null): string
    {
        [$templateRootPaths] = $this->resolveTemplatePaths($this->templatePaths);

        foreach (array_reverse($templateRootPaths) as $rootPath) {
            $filename = $this->resolveFilename($templatePath, $rootPath, $format ?? 'hbs');

            if (is_file($filename)) {
                return $filename;
            }
        }

        throw new Exception\TemplatePathIsNotResolvable($templatePath);
    }

    public function resolvePartialPath(string $partialPath, ?string $format = null): string
    {
        [, $partialRootPaths] = $this->resolveTemplatePaths($this->templatePaths);

        foreach (array_reverse($partialRootPaths) as $rootPath) {
            $filename = $this->resolveFilename($partialPath, $rootPath, $format ?? 'hbs');

            if (is_file($filename)) {
                return $filename;
            }
        }

        throw new Exception\PartialPathIsNotResolvable($partialPath);
    }
}
Copied!

Wiring the implementation 

Register the custom resolver as the implementation of the TemplateResolver interface in your extension's Services.yaml:

Configuration/Services.yaml
services:
  CPSIT\Typo3Handlebars\Renderer\Template\TemplateResolver:
    alias: Vendor\Extension\Renderer\Template\MyTemplateResolver
Copied!

DataSourceAwareProcessor 

Implement the \CPSIT\Typo3Handlebars\DataProcessing\DataSource\DataSourceAwareProcessor interface to run custom PHP logic during the preProcessing or postProcessing stages of a process-variables processor (or of HANDLEBARSTEMPLATE directly). Unlike standard TYPO3 data processors, implementations receive a DataSourceCollection that gives structured access to all four data sources available at that point in the pipeline.

interface DataSourceAwareProcessor
Fully qualified name
\CPSIT\Typo3Handlebars\DataProcessing\DataSource\DataSourceAwareProcessor
process ( array $variables, DataSourceCollection $collection, ContentObjectRenderer $contentObjectRenderer)

Process the current variable set and return the (modified) array.

param array $variables

Current template variable set.

param DataSourceCollection $collection

All four data sources for the current rendering.

param ContentObjectRenderer $contentObjectRenderer

Current content element renderer.

returntype

array

Reading from DataSourceCollection 

DataSourceCollection::resolve() searches the data sources in priority order and returns the first match. Pass a specific DataSource case to restrict the lookup:

use CPSIT\Typo3Handlebars\DataProcessing\DataSource\DataSource;

// Search all sources (highest priority first)
$table = $collection->resolve('table');

// Search only the processor configuration
$table = $collection->resolve('table', DataSource::ProcessorConfiguration);

// Search two specific sources, in the given order
$table = $collection->resolve('table', [
    DataSource::ProcessorConfiguration,
    DataSource::ContentObjectConfiguration,
]);
Copied!

The four DataSource cases mirror the processorConfiguration , processedData , contentObjectRenderer and contentObjectConfiguration identifiers described in Data sources.

The $key argument may use / to reach into a nested array within the searched source(s), e.g. $collection->resolve('some/nested/key') — see Reaching into nested keys.

By default, a key (or path segment) that cannot be found in any searched source simply yields the given $default value ( null if none is given). Pass optional: false to make resolve() throw \Exception\PathIsMissingInDataSource instead, once none of the searched sources match and no default is configured:

use CPSIT\Typo3Handlebars\Exception;

try {
    $value = $collection->resolve('some/nested/key', optional: false);
} catch (Exception\PathIsMissingInDataSource $exception) {
    // None of the searched sources had "some/nested/key"
}
Copied!

Resolving a data payload via a configurable keyword 

Processors often accept a configuration option that itself points at the payload to work with — for example, an iterable option naming the data source to iterate over (see Resolving a data payload (dataSource / data) for the full resolution rules). DataSourceProvider::provide() covers this pattern in one call: it reads the option named by its $keyword argument (default dataSource ) from DataSource::ProcessorConfiguration and resolves it. Since DataSourceAwareProcessor implementations are instantiated via GeneralUtility::makeInstance() , DataSourceProvider can be injected through the constructor like any other service:

use CPSIT\Typo3Handlebars\DataProcessing\DataSource\DataSourceProvider;
use CPSIT\Typo3Handlebars\Exception;

final readonly class MyPreProcessor implements DataSourceAwareProcessor
{
    public function __construct(
        private DataSourceProvider $dataSourceProvider,
    ) {}

    public function process(
        array $variables,
        DataSourceCollection $collection,
        ContentObjectRenderer $contentObjectRenderer,
    ): array {
        try {
            // Resolves the "iterable" processor option, e.g. "processedData:news"
            $variables['items'] = $this->dataSourceProvider->provide($collection, 'iterable');
        } catch (
            Exception\DataSourceIsNotSupported
            | Exception\DataSourceIsMissingInCollection
            | Exception\PathIsMissingInDataSource
        ) {
            // The "iterable" option is not configured, or invalid
        }

        return $variables;
    }
}
Copied!

Example implementation 

EXT:my_extension/Classes/DataProcessing/MyPreProcessor.php
namespace Vendor\Extension\DataProcessing;

use CPSIT\Typo3Handlebars\DataProcessing\DataSource\DataSource;
use CPSIT\Typo3Handlebars\DataProcessing\DataSource\DataSourceAwareProcessor;
use CPSIT\Typo3Handlebars\DataProcessing\DataSource\DataSourceCollection;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;

final readonly class MyPreProcessor implements DataSourceAwareProcessor
{
    public function process(
        array $variables,
        DataSourceCollection $collection,
        ContentObjectRenderer $contentObjectRenderer,
    ): array {
        $table = $collection->resolve('table', DataSource::ProcessorConfiguration, 'tt_content');
        $variables['tableName'] = $table;

        return $variables;
    }
}
Copied!

Registering the processor 

Reference the processor class by its fully qualified class name in preProcessing or postProcessing :

10 = process-variables
10 {
    variables {
        header = TEXT
        header.field = header
    }

    preProcessing {
        10 = Vendor\Extension\DataProcessing\MyPreProcessor
    }
}
Copied!

The numeric keys control execution order when multiple processors are registered. The class is instantiated via GeneralUtility::makeInstance() , so constructor injection works as normal.

PathProvider & VariableProvider 

Two further interfaces allow contributing template paths and global variables from PHP rather than from TypoScript or Services.yaml configuration. Both are auto-registered via #[AutoconfigureTag] and both use a priority integer to control merge order, set via Symfony's #[AsTaggedItem(priority: ...)] attribute on the implementing class.

PathProvider 

Implement the \CPSIT\Typo3Handlebars\Renderer\Template\Path\PathProvider interface to contribute template and partial root paths programmatically — for example when paths depend on the current site configuration or a value not available at container compile time.

Higher priority values are merged last and therefore take precedence over lower ones. The three built-in providers use 0 ( GlobalPathProvider ), 50 ( TypoScriptPathProvider ), and 100 ( ContentObjectPathProvider ).

interface PathProvider
Fully qualified name
\CPSIT\Typo3Handlebars\Renderer\Template\Path\PathProvider
getTemplateRootPaths ( )

Return an array of template root paths, keyed by integer priority.

returntype

array

getPartialRootPaths ( )

Return an array of partial root paths, keyed by integer priority.

returntype

array

isCacheable ( )

Return true if the paths provided by this provider may be cached. Return false for request-dependent paths.

returntype

bool

EXT:my_extension/Classes/Renderer/Template/Path/SitePathProvider.php
namespace Vendor\Extension\Renderer\Template\Path;

use CPSIT\Typo3Handlebars\Renderer\Template\Path\PathProvider;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use TYPO3\CMS\Core\Site\SiteFinder;

#[AsTaggedItem(priority: 25)]
final readonly class SitePathProvider implements PathProvider
{
    public function __construct(
        private SiteFinder $siteFinder,
    ) {}

    public function getTemplateRootPaths(): array
    {
        return [];
    }

    public function getPartialRootPaths(): array
    {
        return [];
    }

    public function isCacheable(): bool
    {
        return false;
    }
}
Copied!

The class is picked up automatically because PathProvider carries #[AutoconfigureTag('handlebars.template_path_provider')] . No extra Services.yaml entry is needed beyond standard autowiring. The #[AsTaggedItem(priority: ...)] attribute on the class determines merge order; without it, the provider defaults to priority 0.

VariableProvider 

Implement the \CPSIT\Typo3Handlebars\Renderer\Variables\VariableProvider interface to inject variables into every template rendering without repeating them in TypoScript. Common uses include a site-wide locale string, feature flags, or shared navigation data.

Providers are merged in ascending priority order; a higher-priority provider can overwrite keys from a lower-priority one. The built-in GlobalVariableProvider uses priority 0.

interface VariableProvider
Fully qualified name
\CPSIT\Typo3Handlebars\Renderer\Variables\VariableProvider
get ( )

Return the variables contributed by this provider.

returntype

array

isCacheable ( )

Return true if the variables provided by this provider may be cached. Return false for request-dependent variables. If any registered provider returns false , the merged VariableBag result is not cached and is recomputed on every access.

returntype

bool

EXT:my_extension/Classes/Renderer/Variables/SiteVariableProvider.php
namespace Vendor\Extension\Renderer\Variables;

use CPSIT\Typo3Handlebars\Renderer\Variables\VariableProvider;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use TYPO3\CMS\Core\Site\SiteFinder;

#[AsTaggedItem(priority: 10)]
final readonly class SiteVariableProvider implements VariableProvider
{
    public function __construct(
        private SiteFinder $siteFinder,
    ) {}

    public function get(): array
    {
        return [
            'siteName' => $this->siteFinder->getSiteByPageId(0)->getIdentifier(),
        ];
    }

    public function isCacheable(): bool
    {
        return true;
    }

    public function offsetExists(mixed $offset): bool
    {
        return array_key_exists($offset, $this->get());
    }

    public function offsetGet(mixed $offset): mixed
    {
        return $this->get()[$offset] ?? null;
    }

    public function offsetSet(mixed $offset, mixed $value): never
    {
        throw new \LogicException('Variables are read-only.', 1781693633);
    }

    public function offsetUnset(mixed $offset): never
    {
        throw new \LogicException('Variables are read-only.', 1781693639);
    }
}
Copied!

Like PathProvider , the class is picked up automatically via #[AutoconfigureTag('handlebars.variable_provider')] on the interface. The #[AsTaggedItem(priority: ...)] attribute on the class determines merge order; without it, the provider defaults to priority 0.

MediaProcessor 

The media data processor resolves a file resource and hands it to whichever registered media processor's supports() method matches it first. Implement the interface yourself to support whatever resource kinds you need (images, documents, videos, download links, ...).

The interface 

interface MediaProcessor
Fully qualified name
\CPSIT\Typo3Handlebars\DataProcessing\Media\MediaProcessor
process ( contentObjectRenderer, resource, configuration = [])

Process the given resource and return the resulting array, which is stored under media 's as key.

param ContentObjectRenderer contentObjectRenderer

The current content object renderer.

param resource

The resolved resource — a core ResourceInterface or an Extbase File / FileReference .

param array configuration

This processor's slice of config.<name> .

returntype

array

supports ( resource)

Return true if this processor can handle the given resource. Called for every registered media processor, in priority order, until one returns true .

param mixed resource

The resolved resource, of unknown type.

returntype

bool

Implement a media processor 

Implementations are auto-registered because the interface itself carries #[AutoconfigureTag('handlebars.media_processor')] . The #[AsTaggedItem('<name>')] attribute on the implementing class both determines matching order among several processors (higher priority is tried first, default 0, same as PathProvider and VariableProvider) and gives the processor its <name> , i.e. the key under which media looks up its config.<name> block.

EXT:my_extension/Classes/DataProcessing/Media/DownloadProcessor.php
namespace Vendor\Extension\DataProcessing\Media;

use CPSIT\Typo3Handlebars\DataProcessing\Media\MediaProcessor;
use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;
use TYPO3\CMS\Core\Resource\AbstractFile;
use TYPO3\CMS\Core\Resource\ResourceInterface;
use TYPO3\CMS\Extbase\Domain\Model\File;
use TYPO3\CMS\Extbase\Domain\Model\FileReference;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;

#[AsTaggedItem('download')]
final readonly class DownloadProcessor implements MediaProcessor
{
    public function process(
        ContentObjectRenderer $contentObjectRenderer,
        ResourceInterface|File|FileReference $resource,
        array $configuration = [],
    ): array {
        return [
            'url' => $resource->getPublicUrl(),
            'label' => $configuration['label'] ?? $resource->getName(),
        ];
    }

    public function supports(mixed $resource): bool
    {
        return $resource instanceof AbstractFile && !$resource->isImage();
    }
}
Copied!

With this registered, config.download.label becomes available wherever media is used.

Typed configuration with ConfigurableProcessor 

Mapping configuration by hand, as above, is fine for a couple of options. For more involved configuration, extend the abstract \CPSIT\Typo3Handlebars\DataProcessing\Media\ConfigurableProcessor instead, which uses cuyz/valinor to map the raw configuration array onto a typed, immutable configuration object before processFile() is called:

/**
 * @extends ConfigurableProcessor<MyConfiguration>
 */
final class MyProcessor extends ConfigurableProcessor
{
    public function processFile(
        ContentObjectRenderer $contentObjectRenderer,
        ResourceInterface|File|FileReference $resource,
        Configuration $configuration,
    ): array {
        // $configuration is an instance of MyConfiguration
    }

    public function supports(mixed $resource): bool
    {
        // ...
    }

    protected function getConfigurationClass(): string
    {
        return MyConfiguration::class;
    }
}
Copied!

MyConfiguration only needs to implement the empty marker interface \CPSIT\Typo3Handlebars\DataProcessing\Media\Configuration\Configuration and declare its accepted options as constructor-promoted properties.

Guides 

This section contains task-oriented guides for common integration and migration scenarios.

Migration from Fluid 

This chapter is aimed at developers who are familiar with TYPO3's built-in Fluid templating engine and want to adopt Handlebars for new or existing projects. It explains the conceptual differences, maps Fluid constructs to their Handlebars equivalents, and provides a step-by-step path for migrating an existing project incrementally without a big-bang rewrite.

Concept mapping 

Fluid and Handlebars share the same goal — separating presentation from logic — but take different philosophical stances on how much the template language itself should do. Understanding this difference up front makes the rest of the migration straightforward.

The logic-less philosophy 

Fluid templates can contain complex expressions: inline ViewHelper chaining, boolean operators, type-coercing comparisons, and dynamic dispatch to arbitrary PHP classes. This power comes at the cost of templates that are hard to read outside of a PHP context.

Handlebars is explicitly "logic-less". Templates may only output values, iterate over arrays, and branch on truthiness. All other logic must live in a named helper — a PHP callable registered with the renderer. This constraint keeps templates readable by designers and front-end developers who do not know PHP, and it shifts complexity to a layer that can be unit-tested cleanly.

Concept-by-concept mapping 

The table below maps every major Fluid concept to its Handlebars equivalent. Detailed examples for each row are given in the linked pages.

Fluid Handlebars See
{variable} {{variable}} (HTML-escaped) Template syntax
{variable -> f:format.raw()} {{{variable}}} (triple-stash, raw) Template syntax
<f:if condition="..."> , <f:else> {{#if ...}}, {{else}}, {{#unless}} Template syntax
<f:for each="{list}" as="item"> {{#each list}}, {{this}}, @index Template syntax
<f:render partial="Name" /> {{> Name}} Template syntax
<f:render partial="Name" arguments="{key: value}" /> {{> Name key=value}} Template syntax
<f:layout name="Main" /> {{#extend "Main"}} … {{/extend}} Layouts and partials
<f:section name="Main"> (in layout) {{#block "main"}} … {{/block}} Layouts and partials
<f:section name="Main"> (in template) {{#content "main"}} … {{/content}} Layouts and partials
ViewHelper class Helper callable + #[AsHelper] attribute Helpers
<f:translate key="..." /> Custom translate helper Helpers
<f:format.date format="..." /> Custom formatDate helper Helpers
Variables from controller assign() TypoScript variables block Gradual migration
plugin.tx_myext.view.templateRootPaths plugin.tx_handlebars.view.templateRootPaths Template paths

Things Handlebars does not have 

Some Fluid features have no direct equivalent and require a different approach:

Inline ViewHelper chains
Fluid allows {value -> f:format.trim() -> f:format.upper()} . In Handlebars, compose the same logic in a single helper that applies both transformations.
Arithmetic and boolean operators in templates
Fluid supports {a + b} and {a && b} in some contexts. In Handlebars, compute the result in a data processor or a helper and expose it as a plain variable.
Type-aware comparisons
<f:if condition="{count} > 0"> works in Fluid because it parses the expression. Handlebars {{#if count}} only tests truthiness — 0 and the empty string are falsy, everything else is truthy. Write a helper if you need a numeric comparison.
Named format strings in the template
Fluid's <f:format.*> ViewHelpers apply a PHP function to a value. Replace each one with a small helper whose name describes the transformation (e.g., {{formatDate date format="d.m.Y"}}).

Template syntax 

This page provides side-by-side examples of the most common Fluid constructs and their Handlebars counterparts. The examples assume a content element with the variables header , bodytext , items , and image .

Outputting a variable 

Handlebars HTML-escapes every {{...}} expression by default.

Fluid:

{header}
Copied!

Handlebars:

{{header}}
Copied!

Raw / unescaped output 

Use triple braces to output a value without HTML escaping. Reserve this for content that has already been sanitized (e.g., a parseFunc -processed RTE field).

Fluid:

{bodytext -> f:format.raw()}
Copied!

Handlebars:

{{{bodytext}}}
Copied!

Conditionals 

{{#if}} is truthy: empty strings, 0 , empty arrays, and null are all falsy. For numeric comparisons, write a helper (see Helpers).

Fluid:

<f:if condition="{header}">
    <h1>{header}</h1>
</f:if>

<f:if condition="{showTeaser}">
    <f:then><p>{teaser}</p></f:then>
    <f:else><p>{fallback}</p></f:else>
</f:if>
Copied!

Handlebars:

{{#if header}}
    <h1>{{header}}</h1>
{{/if}}

{{#if showTeaser}}
    <p>{{teaser}}</p>
{{else}}
    <p>{{fallback}}</p>
{{/if}}
Copied!

Use {{#unless}} as shorthand for a negated {{#if}} without an else branch:

Fluid:

<f:if condition="{hideDate}">
    <f:else><time></time></f:else>
</f:if>
Copied!

Handlebars:

{{#unless hideDate}}
    <time></time>
{{/unless}}
Copied!

Loops 

Inside {{#each}}, {{this}} refers to the current item and @index holds the zero-based iteration counter. @first and @last are boolean flags for the boundary items.

Fluid:

<f:for each="{items}" as="item" iteration="loop">
    <li class="{f:if(condition: loop.isFirst, then: 'is-first')}">
        {item.title}
    </li>
</f:for>
Copied!

Handlebars:

{{#each items}}
    <li{{#if @first}} class="is-first"{{/if}}>
        {{this.title}}
    </li>
{{/each}}
Copied!

Nested {{#each}} blocks access the parent scope via ../:

{{#each categories}}
    <h2>{{this.title}}</h2>
    {{#each this.items}}
        <p>{{this.label}} (category: {{../title}})</p>
    {{/each}}
{{/each}}
Copied!

Scoping with {{#with}} 

{{#with}} sets a new scope root, similar to assigning a sub-object and then using it directly. Inside the block, properties of the given object are accessible without a prefix.

Fluid (using a variable alias via f:alias):

<f:alias map="{addr: '{data.address}'}">
    {addr.street}, {addr.city}
</f:alias>
Copied!

Handlebars:

{{#with data.address}}
    {{street}}, {{city}}
{{/with}}
Copied!

Partials 

Handlebars partials are resolved relative to the configured partial root paths in the same way as templates. The partial name is the filename without the .hbs extension.

Fluid:

<f:render partial="Teaser" />

<f:render partial="Card" arguments="{title: item.title, image: item.image}" />
Copied!

Handlebars:

{{> Teaser}}

{{> Card title=item.title image=item.image}}
Copied!

To pass the entire current context to the partial (as Fluid does with the arguments="{_all}" attribute), just omit any arguments:

{{> Teaser}}
Copied!

To pass a completely different context object, provide it as a positional argument before any hash arguments:

{{> Card item}}
Copied!

Dynamic property access 

Handlebars dot-path notation resolves nested public properties: {{user.address.city}}. For getter resolution and dynamic key lookups (where the key itself is a variable), use the built-in get helper:

Fluid:

{object.{dynamicKey}}
{object.privateProperty.arrayKey}
Copied!

Handlebars:

{{get object dynamicKey}}
{{get object 'privateProperty.arrayKey'}}
Copied!

Comments 

Handlebars comments are stripped from the rendered output and never appear in the HTML source. Use them for template-internal notes.

Fluid:

<!-- this comment appears in HTML source -->
Copied!

Handlebars:

{{!-- this comment is stripped from the output --}}
Copied!

Escaping Handlebars delimiters 

To output a literal {{ in the rendered HTML, use the raw block syntax:

{{{raw}}}}
    This {{{will not be}}} parsed as Handlebars.
{{{{/raw}}}}
Copied!

Layouts and partials 

Fluid's layout system — <f:layout> , <f:section> , and <f:render section="..."> — is one of the first things developers look for when switching template engines. EXT:handlebars ships an equivalent mechanism implemented as the extend, block, and content helpers, modelled after the handlebars-layouts convention.

How the systems compare 

Fluid uses a push model: a child template declares which layout it inherits and then pushes named sections into the layout's named slots. Handlebars uses the same model, but the building blocks are ordinary helpers rather than dedicated language constructs.

Fluid Handlebars
<f:layout name="Default" /> {{#extend "default"}} … {{/extend}}
<f:render section="Main"> (in layout) {{#block "main"}} … {{/block}}
<f:section name="Main"> (in template) {{#content "main"}} … {{/content}}

The content helper supports an optional mode hash argument (replace / append / prepend) that controls how the child's content is merged with the layout block's default. replace is the default and matches Fluid's behaviour.

Side-by-side example 

Layout file — Fluid:

EXT:my_extension/Resources/Private/Layouts/Default.html
<!DOCTYPE html>
<html>
<head>
    <f:render section="Head" optional="true" />
</head>
<body>
    <header>
        <f:render section="Header" />
    </header>
    <main>
        <f:render section="Main" />
    </main>
    <footer>
        <f:render section="Footer" optional="true" />
    </footer>
</body>
</html>
Copied!

Layout file — Handlebars:

EXT:my_extension/Resources/Private/Partials/default.hbs
<!DOCTYPE html>
<html>
<head>
    {{#block "head"}}{{/block}}
</head>
<body>
    <header>
        {{#block "header"}}{{/block}}
    </header>
    <main>
        {{#block "main"}}{{/block}}
    </main>
    <footer>
        {{#block "footer"}}{{/block}}
    </footer>
</body>
</html>
Copied!

Child template — Fluid:

EXT:my_extension/Resources/Private/Templates/MyElement.html
<f:layout name="Main" />

<f:section name="Head">
    <title>{header}</title>
</f:section>

<f:section name="Header">
    <h1>{header}</h1>
</f:section>

<f:section name="Main">
    <p>{bodytext}</p>
</f:section>
Copied!

Child template — Handlebars:

EXT:my_extension/Resources/Private/Templates/my-element.hbs
{{#extend "Main"}}
    {{#content "head"}}
        <title>{{header}}</title>
    {{/content}}

    {{#content "header"}}
        <h1>{{header}}</h1>
    {{/content}}

    {{#content "main"}}
        <p>{{bodytext}}</p>
    {{/content}}
{{/extend}}
Copied!

Default content in blocks 

A {{#block}} can hold default markup that is used verbatim when the child provides no matching {{#content}} for that slot. This is the equivalent of Fluid's optional="true" on <f:render section="..."> combined with a fallback in the layout.

EXT:my_extension/Resources/Private/Partials/Main.hbs
<footer>
    {{#block "footer"}}
        <p>&copy; {{year}} My Site</p>
    {{/block}}
</footer>
Copied!

A child template that does not declare a {{#content "footer"}} block will render the default copyright line automatically.

Appending and prepending content 

The mode argument lets a child add to a block rather than replace it. This has no direct Fluid equivalent and is often used for accumulating <script> or <link> tags:

EXT:my_extension/Resources/Private/Templates/my-element.hbs
{{#extend "Main"}}
    {{#content "head" mode="append"}}
        <link rel="stylesheet" href="/assets/my-element.css">
    {{/content}}

    {{#content "main"}}{{/content}}
{{/extend}}
Copied!

Using partials without a layout 

Not every template needs a full layout. Reusable snippets that were Fluid partials map directly to Handlebars partials with no extra ceremony — just create a .hbs file in the partial root path and include it with {{> Name}}.

Helpers 

Fluid ViewHelpers and Handlebars helpers serve the same role: they bring PHP logic into templates. The implementation model is different enough to warrant a dedicated page.

ViewHelpers vs. Helpers 

A Fluid ViewHelper is a PHP class that implements \TYPO3Fluid\Fluid\Core\ViewHelper\AbstractViewHelper . Arguments are declared via initializeArguments() and the class is resolved by its namespace prefix (e.g., f: , myext: ).

A Handlebars helper is any PHP callable registered via the :php: #[AsHelper] attribute. Arguments reach the callable either as named hash arguments (name=value pairs after the helper name) or as positional arguments (bare values in order). There is no argument declaration step — the method signature is the contract.

Fluid ViewHelper Handlebars helper
Class extending AbstractViewHelper Any class / method / callable
Namespace prefix in template Plain identifier string
initializeArguments() Method parameters
renderChildren() $options->fn($options->scope)
Registered via namespace import #[AsHelper('name')] attribute

Porting an inline ViewHelper 

Inline ViewHelpers that transform a single value (like <f:format.date> ) are the most common case. The Handlebars equivalent is a helper that receives the value as a positional argument and returns the formatted string.

Fluid:

<f:format.date format="d.m.Y">{date}</f:format.date>

{date -> f:format.date(format: 'd.m.Y')}
Copied!

Handlebars:

{{formatDate date format="d.m.Y"}}
Copied!

Helper implementation:

EXT:my_extension/Classes/Renderer/Helper/FormatDateHelper.php
namespace Vendor\Extension\Renderer\Helper;

use CPSIT\Typo3Handlebars\Attribute\AsHelper;
use DevTheorem\Handlebars\HelperOptions;

#[AsHelper('formatDate')]
final readonly class FormatDateHelper
{
    public function __invoke(HelperOptions $options, ?\DateTimeInterface $date = null): ?string
    {
        $format = is_string($options->hash['format'] ?? null)
            ? $options->hash['format']
            : 'd.m.Y';

        return $date?->format($format);
    }
}
Copied!

Porting a block / wrapping ViewHelper 

ViewHelpers that wrap inner content (block ViewHelpers) correspond to Handlebars block helpers. The inner content is rendered via $options->fn($options->scope) and the inverse ({{else}}) branch via $options->inverse($options->scope) .

Fluid:

<myext:ifGranted role="ADMIN">
    <a href="/admin">Admin panel</a>
</myext:ifGranted>
Copied!

Handlebars:

{{#ifGranted role="ADMIN"}}
    <a href="/admin">Admin panel</a>
{{/ifGranted}}
Copied!

Helper implementation:

EXT:my_extension/Classes/Renderer/Helper/IfGrantedHelper.php
namespace Vendor\Extension\Renderer\Helper;

use CPSIT\Typo3Handlebars\Attribute\AsHelper;
use DevTheorem\Handlebars\HelperOptions;
use Vendor\Extension\Security\AccessChecker;

#[AsHelper('ifGranted')]
final readonly class IfGrantedHelper
{
    public function __construct(
        private AccessChecker $accessChecker,
    ) {}

    public function __invoke(HelperOptions $options): string
    {
        $role = $options->hash['role'] ?? '';

        if ($this->accessChecker->isGranted((string)$role)) {
            return (string)$options->fn($options->scope);
        }

        return (string)$options->inverse($options->scope);
    }
}
Copied!

Common ViewHelper equivalents 

The table below lists frequently used Fluid ViewHelpers and how to handle them in Handlebars templates.

Fluid ViewHelper Handlebars approach
f:if Built-in {{#if}} / {{#unless}}
f:for Built-in {{#each}}
f:alias Built-in {{#with}}
f:format.raw Triple-stash {{{variable}}}
f:format.htmlspecialchars Default {{variable}} (always escapes)
f:format.date Custom formatDate helper
f:format.number Custom formatNumber helper
f:translate Custom translate helper (use TYPO3 API inside)
f:uri.page , f:link.* Custom URI helper (use UriBuilder inside)
f:image Custom image helper (use TYPO3 image API inside)
f:render partial="…" {{> PartialName}} or {{render "PartialName"}}
f:debug Built-in {{debug}} helper

Fluid ViewHelper bridge 

As a temporary migration aid, the extension ships a viewHelper helper that invokes any registered Fluid ViewHelper directly from a Handlebars template. This lets you use existing ViewHelpers without writing a wrapper immediately.

{{viewHelper "f:format.date" date=someDate format="d.m.Y"}}

{{viewHelper "myext:widget.paginate" objects=items as="pagedItems"}}

{{#viewHelper "myext:security.ifGranted" role="ADMIN"}}
    <a href="/admin">Admin panel</a>
{{/viewHelper}}
Copied!

To use ViewHelpers from a custom namespace, register the namespace first with viewHelperNamespace:

{{viewHelperNamespace "tx" "https://typo3.org/ns/Vendor/Extension/ViewHelpers"}}
{{viewHelper "tx:myHelper" someArg=value}}
Copied!

Gradual migration 

Replacing every Fluid template in one step is rarely practical. This page describes an incremental approach that lets Fluid and Handlebars coexist in the same TYPO3 installation — even within the same extension — so you can migrate one content element or controller at a time.

Strategy overview 

The recommended path has three lanes that can be worked independently:

  • Content elements rendered via tt_content.* TypoScript: Replace the content object type from FLUIDTEMPLATE to HANDLEBARSTEMPLATE one CType at a time.
  • Extbase controllers: Extend HandlebarsController , then migrate templates action by action with a Fluid fallback in place.
  • Shared partials / layout: Migrate the layout shell last, once all templates that depend on it have been converted.
  1. Install and configure paths

    Install the extension and include the base site set without the content-element set. The content-element set replaces lib.contentElement globally; omitting it keeps all existing content elements on Fluid.

    config/sites/<site>/config.yaml
    dependencies:
      - cpsit/handlebars   # base set only — does NOT touch lib.contentElement
    Copied!

    Configure template and partial root paths for Handlebars independently of the Fluid paths:

    plugin.tx_handlebars {
        view {
            templateRootPaths {
                10 = EXT:my_sitepackage/Resources/Private/Templates/Handlebars
            }
            partialRootPaths {
                10 = EXT:my_sitepackage/Resources/Private/Partials/Handlebars
            }
        }
    }
    Copied!

    Fluid and Handlebars path registrations are completely independent, so there is no risk of one system picking up the other's files.

  2. Migrate content elements one at a time

    For each content element, convert the TypoScript definition from FLUIDTEMPLATE to HANDLEBARSTEMPLATE and create the corresponding .hbs file.

    Before (Fluid):

    tt_content.tx_myext_teaser = FLUIDTEMPLATE
    tt_content.tx_myext_teaser {
        templateName = Teaser
        templateRootPaths.10 = EXT:my_extension/Resources/Private/Templates
        partialRootPaths.10 = EXT:my_extension/Resources/Private/Partials
        layoutRootPaths.10 = EXT:my_extension/Resources/Private/Layouts
        variables {
            title = TEXT
            title.field = header
        }
        dataProcessing {
            10 = TYPO3\CMS\Frontend\DataProcessing\FilesProcessor
            10 {
                references.fieldName = image
                as = images
            }
        }
    }
    Copied!

    After (Handlebars):

    tt_content.tx_myext_teaser = HANDLEBARSTEMPLATE
    tt_content.tx_myext_teaser {
        templateName = Teaser
        templateRootPaths.10 = EXT:my_extension/Resources/Private/Templates/Handlebars
        partialRootPaths.10 = EXT:my_extension/Resources/Private/Partials/Handlebars
        variables {
            title = TEXT
            title.field = header
        }
        dataProcessing {
            10 = files
            10 {
                references.fieldName = image
                as = images
            }
        }
    }
    Copied!

    All other tt_content.* definitions that are not yet migrated continue to use Fluid without any changes.

  3. Migrate Extbase controllers

    For controller-based rendering, the extension provides HandlebarsViewFactory , which replaces TYPO3's default FluidViewFactory . It inspects the TypoScript configuration and returns a HandlebarsView when Handlebars configuration is present, or falls back to the Fluid view when it is not.

    Extend HandlebarsController:

    For controllers you own, extend \CPSIT\Typo3Handlebars\Controller\HandlebarsController instead of ActionController . The renderView() method renders the current action via Handlebars:

    EXT:my_extension/Classes/Controller/BlogController.php
    namespace Vendor\Extension\Controller;
    
    use CPSIT\Typo3Handlebars\Controller\HandlebarsController;
    use Psr\Http\Message\ResponseInterface;
    
    final class BlogController extends HandlebarsController
    {
        public function listAction(): ResponseInterface
        {
            $this->view->assign('posts', $this->postRepository->findAll());
    
            return $this->htmlResponse($this->renderView());
        }
    }
    Copied!

    The view factory resolves the template name automatically from the controller alias and action name (e.g., Blog/list.hbs for listAction ). Configure paths and per-action overrides via the handlebars key in the plugin's TypoScript namespace:

    plugin.tx_myextension_myplugin {
        handlebars {
            default {
                templateRootPaths.10 = EXT:my_extension/Resources/Private/Templates/Handlebars
                partialRootPaths.10 = EXT:my_extension/Resources/Private/Partials/Handlebars
            }
        }
    }
    Copied!

    Using the Fluid fallback during migration:

    When a controller action has not yet been ported, call delegateRendering() on the view to delegate to the underlying Fluid view. This keeps the action working while the template is being migrated:

    public function legacyAction(): ResponseInterface
    {
        $this->view->assign('items', $this->repository->findAll());
    
        // Render with Fluid until the .hbs template is ready
        $content = $this->view instanceof HandlebarsView
            ? $this->view->delegateRendering()
            : $this->view->render();
    
        return $this->htmlResponse((string)$content);
    }
    Copied!
  4. Migrate the page layout shell

    The layout shell (the outer HTML document, header, navigation, footer) is typically the last thing to migrate because it is shared by every page. Keep the existing Fluid layout in place until all content elements and actions that reference it have been converted to Handlebars partials.

    Once all consumers are migrated, convert the Fluid layout to a Handlebars layout partial using the extend / block / content pattern described in Layouts and partials.

  5. Switch the lib.contentElement default

    After every content element has been migrated to HANDLEBARSTEMPLATE , include the cpsit/handlebars-content-element site set. This sets lib.contentElement = HANDLEBARSTEMPLATE globally so that newly created content elements start from Handlebars automatically:

    config/sites/<site>/config.yaml
    dependencies:
      - cpsit/handlebars
      - cpsit/handlebars-content-element
    Copied!

Migration 

This page lists required migration steps when upgrading to a new major version of the extension.

Version 1.0.0 

Version 1.0.0 replaces the previous PHP-class rendering model (DataProcessor / DataProvider / Presenter) with the HANDLEBARSTEMPLATE content object. All rendering configuration moves to TypoScript; custom PHP classes are no longer the entry point.

Removed classes and interfaces 

The following classes and interfaces have been removed and have no replacement:

  • \CPSIT\Typo3Handlebars\DataProcessing\DataProcessor (interface)
  • \CPSIT\Typo3Handlebars\DataProcessing\AbstractDataProcessor
  • \CPSIT\Typo3Handlebars\Data\DataProvider (interface)
  • \CPSIT\Typo3Handlebars\Data\Response\ProviderResponse (interface)
  • \CPSIT\Typo3Handlebars\Presenter\Presenter (interface)
  • \CPSIT\Typo3Handlebars\Presenter\AbstractPresenter
  • \CPSIT\Typo3Handlebars\DataProcessing\SimpleProcessor

The handlebars.processor service tag has also been removed.

TypoScript entry point 

Before: Each content element was routed to a DataProcessor class via a USER content object:

tt_content.header = USER
tt_content.header.userFunc = Vendor\Extension\DataProcessing\HeaderProcessor->process
Copied!

After: Use HANDLEBARSTEMPLATE directly:

tt_content.header = HANDLEBARSTEMPLATE
tt_content.header {
    templateName = Header

    variables {
        header = TEXT
        header.field = header

        subheader = TEXT
        subheader.field = subheader
    }
}
Copied!

Data preparation (DataProvider → variables / dataProcessing) 

Before: Data was prepared in a DataProvider class and returned as a ProviderResponse object, which the Presenter then passed to the renderer.

After: Data is prepared entirely in TypoScript:

  • Simple field values: use variables with content objects such as TEXT , FILES , etc.
  • Database relations and menus: use standard TYPO3 dataProcessing processors (e.g., database-query , menu ).
  • Per-record variable processing inside a loop: use process-variables.

Template selection (Presenter → templateName) 

Before: The Presenter called $this->renderer->render('path/to/template', $data) .

After: The template is declared in TypoScript:

tt_content.my_element {
    templateName = MyElement
}
Copied!

For conditional template selection, use stdWrap on templateName :

tt_content.my_element {
    templateName = MyElement
    templateName.override.if {
        isTrue.field = tx_myext_variant
        value = special
    }
    templateName.override = MyElementSpecial
}
Copied!

Helper registration 

Before: Helpers were registered via Services.yaml tags:

Configuration/Services.yaml
services:
  Vendor\Extension\Renderer\Helper\GreetHelper:
    tags:
      - name: handlebars.helper
        identifier: 'greet'
        method: 'greetById'
Copied!

After: Use the #[AsHelper] attribute directly on the class or method:

EXT:my_extension/Classes/Renderer/Helper/GreetHelper.php
use CPSIT\Typo3Handlebars\Attribute\AsHelper;
use CPSIT\Typo3Handlebars\Renderer\Helper\Helper;
use DevTheorem\Handlebars\HelperOptions;

#[AsHelper('greet')]
final readonly class GreetHelper implements Helper
{
    public function render(HelperOptions $options): string { /* ... */ }
}
Copied!

The Services.yaml tag approach still works and can be used if you cannot modify the helper class (e.g., a third-party class).

Debug mode 

The debug mode built into HandlebarsRenderer has been removed. Previously, when TYPO3's config.debug flag or $GLOBALS['TYPO3_CONF_VARS']['FE']['debug'] was enabled, the renderer automatically bypassed the template cache and switched the Handlebars compiler to strict mode (throwing on missing variables instead of silently returning empty strings).

There is no automatic replacement tied to a debug flag. Use the following alternatives instead:

  • Cache bypass: Disable caching explicitly via TYPO3's caching framework configuration or by setting config.no_cache = 1 during development.
  • Strict compilation: Enable rendering.strictMode in the extension configuration. Unlike the previous behavior, this is a persistent setting rather than one tied to TYPO3's debug flags.
  • Strict template validation: Use the shipped {{debug}} helper inside templates to inspect variable values at render time. For programmatic checks, call Handlebars::precompile() directly to inspect the generated PHP code.

Template path configuration 

Template path configuration via Services.yaml and TypoScript remains unchanged. In addition, paths can now also be set per-content-object directly in HANDLEBARSTEMPLATE :

tt_content.textmedia = HANDLEBARSTEMPLATE
tt_content.textmedia {
    templateRootPaths.10 = EXT:my_extension/Resources/Private/Templates
    partialRootPaths.10 = EXT:my_extension/Resources/Private/Partials
}
Copied!

Contribution guide 

Thanks for considering contributing to this extension! Since it is an open source product, its successful further development depends largely on improving and optimizing it together.

The development of this extension follows the official TYPO3 coding standards. To ensure the stability and cleanliness of the code, various code quality tools are used and most components are covered with test cases. In addition, we use DDEV for local development. Make sure to set it up as described below. For continuous integration, we use GitHub Actions.

Preparation 

# Clone repository
git clone https://github.com/CPS-IT/handlebars.git
cd handlebars

# Install dependencies
composer install
Copied!

Development workflow 

A typical contribution workflow looks like this:

  1. Apply automatic fixes

    Use the following commands to normalize and format the code base:

    # Apply all automatic fixes
    composer fix
    
    # Apply specific fixes
    composer fix:composer
    composer fix:editorconfig
    composer fix:php
    Copied!
  2. Run checks

    Use composer check to run the full code quality pipeline locally. This command bundles dependency analysis, static analysis, coding style checks, and Rector in dry-run mode so that potential refactorings can be reviewed without changing files.

    # Run all checks
    composer check
    
    # Run specific checks
    composer check:deps
    composer check:refactor
    composer check:static
    composer check:style
    
    # Run specific style checks
    composer check:style:composer
    composer check:style:editorconfig
    composer check:style:php
    composer check:style:typoscript
    Copied!
  3. Run refactorings

    Refactorings are intentionally separated from regular checks because they may change the code base.

    # Run all configured refactorings
    composer refactor
    
    # Run specific refactorings
    composer refactor:php
    Copied!
  4. Run tests

    Run the full test suite before opening a pull request:

    # Run all tests
    ddev composer test
    ddev composer test:coverage
    
    # Run functional tests
    ddev composer test:functional
    ddev composer test:functional:coverage
    
    # Run unit tests
    ddev composer test:unit
    ddev composer test:unit:coverage
    
    # Merge coverage reports
    ddev composer test:merge-coverage
    Copied!

Coverage reports 

Code coverage reports are written to Build/tests/coverage. Open the latest merge HTML report with:

open Build/tests/coverage/html/_merged/index.html
Copied!

Pull requests 

Once the changes are ready, please submit a pull request and describe what was changed and why. Ideally, the pull request references an issue that describes the problem being solved.

All documented code quality tools are executed automatically for pull requests across the currently supported PHP versions. For details, refer to the GitHub Actions workflows.

Sitemap