TYPO3

#[AsAllowedCallable] in TYPO3 v14: fixing userFunc errors

5 min read

userFunc needs an opt-in now

You upgrade to TYPO3 v14, the backend looks fine, the start page renders, and then one page shows an error like this:

Attribute TYPO3CMSCoreAttributeAsAllowedCallable required for
callback reference: ["Vendor\Sitepackage\UserFunc\Copyright","render"]

Nothing changed in that class. It worked for years. What happened? In v14 the core changed the rules for which PHP methods TypoScript is allowed to call. This post explains the change, shows how to fix it properly, and how to find all affected places before your editors find them for you.

Why v14 restricts TypoScript callables

TypoScript can call PHP through userFunc, the USER and USER_INT content objects, and stdWrap properties like preUserFunc and postUserFunc. Before v14 any public method of any loadable class was fair game. TypoScript is written by integrators and sometimes stored in the database (sys_template records), so this was a wide door. If someone could change TypoScript, they could call PHP methods that were never meant for it.

Breaking-108054 closes it with an explicit opt-in. A method is callable from TypoScript or TSconfig only if it carries the attribute #[TYPO3CMSCoreAttributeAsAllowedCallable]. According to the changelog this applies to:

  • TypoScript userFunc processing, including USER and USER_INT
  • stdWrap preUserFuncInt, postUserFunc and postUserFuncInt
  • user functions in TypoScript constant comments
  • TSconfig renderFunc of the suggest wizard

The changelog list names preUserFuncInt, which I could not find as a stdWrap property in 14.3, and it does not name preUserFunc. But I looked at ContentObjectRenderer in 14.3: stdWrap_preUserFunc() goes through the same callUserFunction() method, which checks the attribute. So treat it the same. The check lives in TYPO3CMSCoreSecurityAllowedCallableAssertion, and the failure is an AllowedCallableException.

Also note the index line of the changelog: NotScanned. The Extension Scanner will not warn you. You only notice at runtime, on the page which uses the function. That is why I was a bit shocked the first time.

Solution

Step 1: add the attribute

The fix itself is one line per method:

<?php

declare(strict_types=1);

namespace VendorSitepackageUserFunc;

use PsrHttpMessageServerRequestInterface;
use TYPO3CMSCoreAttributeAsAllowedCallable;

final class Copyright
{
    #[AsAllowedCallable]
    public function render(string $content, array $conf, ServerRequestInterface $request): string
    {
        $from = (int)($conf['from'] ?? date('Y'));
        $to = (int)date('Y');
        return $from === $to ? (string)$to : $from . '–' . $to;
    }
}
lib.copyright = USER
lib.copyright {
  userFunc = VendorSitepackageUserFuncCopyright->render
  from = 2014
}

The core calls the method with three arguments: the content, the TypoScript configuration array, and the request (with the current content object as request attribute currentContentObject). The attribute may sit on public instance methods, public static methods, public __invoke() methods and plain functions in the global namespace. Native PHP functions like strtoupper cannot get an attribute, so if your TypoScript calls one directly, you have to write a tiny wrapper function with the attribute.

Usually the fix works right away: the container collects the attribute at build time, and the check falls back to reflection at runtime, so even a stale DI cache does not block it. If the error stays, first check that you edited exactly the class and method the error message names, then flush the caches anyway: ddev typo3 cache:flush.

Step 2: find every place before the editors do

Don’t wait for error reports. Grep the whole project for every TypoScript property that ends up in callUserFunction():

# TypoScript and TSconfig files in your own extensions and config
grep -rnE "(userFunc|preUserFunc|postUserFunc|postUserFuncInt|renderFunc)s*=" 
  Source/Extension config/sites packages 2>/dev/null

# TypoScript stored in the database (sys_template records)
ddev mysql -e "SELECT uid, pid, title FROM sys_template 
  WHERE deleted = 0 AND (config LIKE '%UserFunc%' OR config LIKE '%userFunc%' OR constants LIKE '%userFunc%')"

Adjust the paths to your project layout. Then list the unique class and method names and check each one. Third-party extensions are affected too: if the error points to a vendor class, look for an update of that extension before you patch anything.

Step 3: think before you mark

The point of the change is that you decide which methods are entry points. So please don’t blindly add the attribute everywhere. Ask for each method:

  • Is it really meant to be called from TypoScript? Then add the attribute.
  • Does it trust its $conf too much, for example by building SQL or file paths from it? Fix that first. With the attribute you are vouching for it.
  • Is it legacy code that nobody uses anymore? Delete the TypoScript and the method. An upgrade is the best moment for this cleanup.

When a DataProcessor is the better tool

Many userFuncs I see in old projects do not render a string at all. They fetch some records, put them into an array, and a Fluid template renders them later through some trick. For that job, a DataProcessor is the right tool. It runs inside FLUIDTEMPLATE or PAGEVIEW, returns data to the view, and the template stays in charge of markup.

DataProcessors are not affected by the new attribute check. The core only accepts classes that implement DataProcessorInterface, which is already an explicit contract. Here is a small one:

<?php

declare(strict_types=1);

namespace VendorSitepackageDataProcessing;

use TYPO3CMSFrontendContentObjectContentObjectRenderer;
use TYPO3CMSFrontendContentObjectDataProcessorInterface;

final class CopyrightProcessor implements DataProcessorInterface
{
    public function process(
        ContentObjectRenderer $cObj,
        array $contentObjectConfiguration,
        array $processorConfiguration,
        array $processedData
    ): array {
        $from = (int)($processorConfiguration['from'] ?? date('Y'));
        $as = $processorConfiguration['as'] ?? 'copyright';
        $processedData[$as] = ['from' => $from, 'to' => (int)date('Y')];
        return $processedData;
    }
}
# Configuration/Services.yaml
services:
  VendorSitepackageDataProcessingCopyrightProcessor:
    tags:
      - name: data.processor
        identifier: copyright
page = PAGE
page.10 = PAGEVIEW
page.10 {
  paths.10 = EXT:sitepackage/Resources/Private/PageView/
  dataProcessing.20 = copyright
  dataProcessing.20.from = 2014
}

In the template you write {copyright.from}–{copyright.to}. The data.processor tag with an identifier is how the core registers its own processors (files, menu, database-query and so on); the full class name in TypoScript works too.

My rule of thumb: a userFunc with the attribute for small strings that integrators place freely, like the copyright year. A DataProcessor whenever data goes to a Fluid template.

Supporting v13 and v14 in one extension

The attribute class does not exist in v13. That is not a problem: PHP only resolves an attribute class when someone calls newInstance() on it, and v13 never asks for this one. So you can add the use statement and the attribute in an extension that supports both lines. I would still run PHPStan against both versions to be sure your config does not complain about the unknown class.

That’s all, folks. One line per method, but please make it a deliberate line. Cheers!

Further reading

Leave a comment

Your email address will not be published.