During the development of TYPO3 9LTS another piece of software from the rich PHP world found its way into the core: The Symfony ExpressionLanguage Component.
First introduced for the system extension form (Patch, Feature RST), the symfony ExpressionLanguage component was converted to a global core dependency shortly after (Patch, Feature RST). Since then the ExpressionLanguage can be used throughout a TYPO3 project and extension developers can extend it to their needs. The core adapted the flexibility of the ExpressionLanguage on multiple places. For example the implementation of multiple domain variants for a single site configuration relies on conditions that are evaluated through the ExpressionLanguage (Patch, Feature RST).
But most impactful was the switch to the ExpressionLanguage for TypoScript conditions (Patch, Feature RST). In TYPO3 9 LTS it is now deprecated (Patch, Deprecation RST) to use the old TypoScript conditions syntax. The old conditions will still work and there is even a feature switch. However, it is recommended for integrators and system maintainers to switch to the new way.
In this post we will look at the ExpressionLanguage to see how it works and how it was implemented in TYPO3. We will look at examples of migrating old TypoScript conditions to the new syntax, and we will see how to extend the functionality and implement custom Providers for conditions that are not covered by the TYPO3 core.
The Symfony ExpressionLanguage
The first question that comes to mind is obviously: What is the ExpressionLanguage? To answer that question we first of all use a quote from the official documentation of the module:
The ExpressionLanguage component provides an engine that can compile and evaluate expressions. An expression is a one-liner that returns a value (mostly, but not limited to, Booleans)
It was build explicitly with the idea of conditions in some kind of configuration language in mind. It aims to provide flexibility in such configurations. The functionality is quite simple: The ExpressionLanguage
evaluates a single line. It can interpret basic things like numbers out of the box. For example, we can perform basic math:
$expressionLanguage = new \Symfony\Component\ExpressionLanguage\ExpressionLanguage();
$expressionLanguage->evaluate('1 + 2'); // 3
$expressionLanguage->evaluate('1 + 2 === 3'); // true
$expressionLanguage->evaluate('1 + 2 < 3'); // false
Of course this is only the beginning. The Expression language can handle objects as well. I'll take (and slightly modify) the example from the official documentation to illustrate a few things: First there is now an object involved ($apple
), second we see the syntax of accessing public properties (object.property
) and calling methods (object.method()
) and finally we see how arguments can be passed to the evaluation as well:
class Apple
{
public $variety;
public function hasVariety()
{
return !empty($this->$variety);
}
}
$apple = new Apple();
$apple->variety = 'Honeycrisp';
$expressionLanguage->evaluate(
'fruit.variety',
['fruit' => $apple]
); // Honeycrisp
$expressionLanguage->evaluate(
'fruit.hasVariety()',
['fruit' => $apple]
); // true
Since the TYPO3 core already has a TypoScript parser that recognizes conditions because they always have to be in squared brackets the first step for an implementation was quite simple. In the AbstractConditionMatcher
the condition is now passed to the ExpressionLanguage. If the evaluation fails the old condition matching kicks in as a fallback. A fallback that will be removed in TYPO3 v10.
So basically the condition matching only needed one change whatsoever:
$result = $this->evaluateExpression($condition);
if (!is_bool($result)) {
$result = $this->evaluateCondition($condition);
}
evaluateExpression()
will try to evaluate the $condition
through the ExpressionLanguage while evaluateCondition()
is the old (and deprecated) TYPO3-self-invented way of evaluating TypoScript conditions. If you are interested in further details have a look into the AbstractConditionMatcher.
To support all features that were supported with the old TypoScript conditions the default ExpressionLanguage module is obviously not sufficient. But of course it can be extended as needed. So the TYPO3 core now comes with a TypoScriptConditionProvider
that extends the functionality of the Expression Language and provides everything to match the old feature set.
We will now look at how this works and learn how to extend the ExpressionLanguage in our own extensions along the way.
Extending the ExpressionLanguage in TYPO3
The ExpressionLanguage can be extended by registering new methods (official documentation) and TYPO3 9LTS provides an API for the registration. To be even more flexible upon extending the ExpressionLanguage TYPO3 expects a wrapper class (called provider) which can provide a set of variables alongside an implementation of the ExpressionFunctionProviderInterface
(see below). To register any of those provider classes a PHP file has to be created within an activated extension: Configuration/ExpressionLanguage.php
. The file needs to simply return an array with the configuration.
Let's have a look at the file provided by the system extension core (that is always active):
<?php
return [
'default' => [
// The DefaultProvider is loaded every time
// \TYPO3\CMS\Core\ExpressionLanguage\DefaultProvider::class,
],
'typoscript' => [
\TYPO3\CMS\Core\ExpressionLanguage\TypoScriptConditionProvider::class,
],
'site' => [
\TYPO3\CMS\Core\ExpressionLanguage\SiteConditionProvider::class,
]
];
We see three providers registered by the core extension. Each provider needs a context ('default'
, 'typoscript'
, 'site'
) and a FQCN pointing to the implementation (a class implementing the \TYPO3\CMS\Core\ExpressionLanguage\ProviderInterface
).
The one responsible for extending the ExpressionLangauge with all the TypoScript Condition features has the context 'typoscript'
. So let's look at that class next:
class TypoScriptConditionProvider extends AbstractProvider
{
public function __construct()
{
$typo3 = new \stdClass();
$typo3->version = TYPO3_version;
$typo3->branch = TYPO3_branch;
$typo3->devIpMask = trim($GLOBALS['TYPO3_CONF_VARS']['SYS']['devIPmask']);
$this->expressionLanguageVariables = [
'request' => GeneralUtility::makeInstance(RequestWrapper::class, $GLOBALS['TYPO3_REQUEST'] ?? null),
'applicationContext' => (string)GeneralUtility::getApplicationContext(),
'typo3' => $typo3,
];
$this->expressionLanguageProviders = [
Typo3ConditionFunctionsProvider::class
];
}
}
This provider does two things:
- Three variables are prepared for the ExpressionLanguage:
- The current PSR-7 request object (
request
) - The application context (
applicationContext
) - A custom
stdClass
holding some basic information about the TYPO3 installation (typo3
).
- The current PSR-7 request object (
- Additionally to the variables the provider also adds methods or functions to the ExpressionLanguage. This is done in a different provider that holds the implementation of the provided methods (
Typo3ConditionFunctionsProvider
).
The naming is a quite confusing because of the different kind of providers involved. Let's try to make it more clear in a quick recap:
TypoScriptConditionProvider
- TYPO3 wrapper to provide variables and functions.Typo3ConditionFunctionsProvider
- Provider for additional functions of the ExpressionLanguage as mentioned in the official documentation.
The first kind of provider is expected to be configured in ExpressionLanguage.php
. If such a provider wants to add functions to the ExpressionLanguage it has to provide those through the second kind.
But now to the Typo3ConditionFunctionsProvider:
class Typo3ConditionFunctionsProvider implements ExpressionFunctionProviderInterface
{
/**
* @return ExpressionFunction[] An array of Function instances
*/
public function getFunctions()
{
return [
$this->getLoginUserFunction(),
$this->getTSFEFunction(),
$this->getUsergroupFunction(),
$this->getSessionFunction(),
$this->getSiteFunction(),
$this->getSiteLanguageFunction(),
];
}
protected function getTSFEFunction(): ExpressionFunction
{
return new ExpressionFunction('getTSFE', function () {
// Not implemented, we only use the evaluator
}, function ($arguments) {
return $GLOBALS['TSFE'];
});
}
...
The structure of a ExpressionFunctionProvider is relatively simple. The getFunctions()
method returns an array of \Symfony\Component\ExpressionLanguage\ExpressionFunction
instances. A ExpressionFunction
implements two methods that can be passed to the constructor alongside the name of the function. The first method is called when the function is used in a ->compile()
context, the second mehtod is called in the ->evaluate()
context of the ExpressionLanguage.
Long story short, the TypoScript conditions are only evaluated and never compiled, so only the second method is implemented. The name can then be used in TypoScriptConditions:
[getTSFE().id == 10]
However, there is a better way for checking against the current page ID. During its initialization in the ConditionMatcher
the ExpressionLanguage in enriched with further information. Let's have a look at this last part in understanding the implementation of the Expressonlanguage in the context of TypoScript conditions:
/**
* @param Context $context optional context to fetch data from
*/
public function __construct(Context $context = null)
{
$this->context = $context ?? GeneralUtility::makeInstance(Context::class);
$this->rootline = $this->determineRootline();
$tree = new \stdClass();
$tree->level = $this->rootline ? count($this->rootline) - 1 : 0;
$tree->rootLine = $this->rootline;
$tree->rootLineIds = array_column($this->rootline, 'uid');
$frontendUserAspect = $this->context->getAspect('frontend.user');
$frontend = new \stdClass();
$frontend->user = new \stdClass();
$frontend->user->isLoggedIn = $frontendUserAspect->get('isLoggedIn') ?? false;
$frontend->user->userId = $frontendUserAspect->get('id') ?? 0;
$frontend->user->userGroupList = implode(',', $frontendUserAspect->get('groupIds'));
$this->expressionLanguageResolver = GeneralUtility::makeInstance(
Resolver::class,
'typoscript',
[
'tree' => $tree,
'frontend' => $frontend,
'page' => $this->getPage(),
]
);
}
This adds information about the rootline (tree
), the current user (frontend
) and the current page record (page
). The information about the current user is stored as frontend
because this snipped was taken from the frotnend ConditionMatcher, while in the backend condition matcher the information is store under the key backend
.
This completes the variables available to the ExpressionLanguage when evaluation TypoScript conditions.
A last hint for your own implementations: In custom functions (see the ExpressionFunction
) all variable are passed to the evaluate method as first argument ($arguments
). With all this in mind we finally understand the implementation of the loginUser
function that is available in TypoScript conditions and is registered with the Typo3ConditionFunctionsProvider
(see getLoginUserFunction()
):
protected function getLoginUserFunction(): ExpressionFunction
{
return new ExpressionFunction('loginUser', function () {
// Not implemented, we only use the evaluator
}, function ($arguments, $str) {
$user = $arguments['backend']->user ?? $arguments['frontend']->user;
if ($user->isLoggedIn) {
foreach (GeneralUtility::trimExplode(',', $str, true) as $test) {
if ($test === '*' || (string)$user->userId === (string)$test) {
return true;
}
}
}
return false;
});
}
Note line 6 where $user
is received from the variable added by the ConditionMatcher
.
You should now be able to extend this functionality with your own provider:
- Register you provider in your extension in
Configuration/ExpressionLanguage.php
- Add variables and functions in your Provider
- Use the key
typoscript
for your provider to extend the TypoScript Condition functionality or introduce your own context. - To make sure all providers for a given context are respected, initialize the ExpressionLanguage through the
\TYPO3\CMS\Core\ExpressionLanguage\Resolver
. The first constructor argument is the context name, the second is an optional array of additional variables for the ExpressionLanguage instance.
So finally let's look at a few examples for the new TypoScript condition syntax.
As an example, have look at the extension host_variants (GitHub) that extends the conditions available in the site configuration.
The new TypoScript Conditions
For a list of what is available in the TypoScript conditions please refer to the Feature RST. Variables and functions are listed there with descriptions.
I will now show some examples of conditions with the new syntax and the old conditions they replace. This is no complete list but an inspiration and a startingpoint for a migration of your projects.
[page["uid"] in 18..45]
# This condition matches if current page uid is between 18 and 45
# Not possible with old syntax
[END]
[34 in tree.rootLineIds || 36 in tree.rootLineIds]
# This condition matches, if the page viewed is or is a subpage to page 34 or page 36
# Old Syntax: [PIDinRootline = 34,36]
[END]
[loginUser('*')]
# Old syntax: [loginUser = *]
[END]
[page["field"] == "value"]
# Old syntax: [page|field = value]
[END]
[loginUser('*') == false]
# Old syntax: [loginUser = ]
[END]
[getTSFE().id >= 10]
# Old Syntax [globalVar = TSFE:id >= 10]
[END]
[applicationContext == "Production" && userId == 15]
# This condition match if application context is "Production" AND logged in user has the uid 15
# Old syntax: [applicationContext = "Production"] && [loginUser = 15]
[END]
[request.getNormalizedParams().getHttpHost() == 'typo3.org']
# This condition matches if current hostname is typo3.org
# Old Syntax: [globalString = IENV:HTTP_HOST = www.typo3.org]
[END]
[like(request.getNormalizedParams().getHttpHost(), "*.devbox.local")]
# This condition matches if current hostname is any subdomain of devbox.local
# Old Syntax: [globalString = IENV:HTTP_HOST = *.devbox.local]
[END]
[page["uid"] in [1,2,3,4] || 5 in tree.rootLineIds]
# This condition matches if current page uid is either 1,2,3 or 4
# or if the current page is 5 or any subpage of 5
[END]
[backend.user.isLoggedIn]
# This condition matches if a backend user is logged in
# Old syntax: [globalVar = TSFE:beUserLogin = 1]
[END]
[traverse(request.getQueryParams(), 'tx_blog_tag/tag') > 0]
# This condition matches if current query parameters have tx_blog_tag[tag] set to a value greater than zero
[END]
With the deprecation of the old syntax, a feature switch was introduce tu supress the deprecation warnings (Deprecation RST).
If $GLOBALS['SYS']['features']['TypoScript.strictSyntax']
is set to false
, the ConditionMatcher will still try the ExpressionLanguage first to evaluate conditions but no deprecation errors will be triggered and the log entries for old syntax usages are of severity debug
instead of warning
.
On a last note I recommend checking out the basic features of the ExpressionLanguage syntax (official documentation) as well as the new stuff that is now available in TypoScript conditions like the site object or the PSR-7 request. Many things are possible, and I am sure the flexible ExpressionLanguage can be used to an even greater benefit in the realm of TypoScript. However, it is one more example for the general direction of moving away from clunky self-invented solutions to slim, efficient modules from the PHP world.
Way to go!
Thanks for reading.