Render arrays and forms¶
The analyzer accepts a response from a form's buildForm(), and checks the arguments that
getForm() passes on to it. The checks report callbacks in render and form arrays that can't be
called, trusted or serialized by core, and cacheable dependencies that can't have cacheability.
Form responses¶
FormBuilder::retrieveForm() accepts a Response from a form's buildForm(). It throws an
EnforcedResponseException, and Drupal sends that response instead of the page. Confirmation forms
use this to redirect when there is nothing left to confirm. FormInterface::buildForm() documents
an array, so Mago reports the return as invalid-return-statement. The analyzer drops that report
when the method belongs to a class that implements FormInterface, and every type that it returns
there is a Response.
public function buildForm(array $form, FormStateInterface $form_state) {
if ($this->selection === []) {
// Not reported.
return new RedirectResponse($this->getCancelUrl()->toString());
}
// ...
}
Mago also reports the return under a native RedirectResponse|array, since the inherited docblock
narrows the native type, and that report is dropped as well.
Still reported:
- a return under a native type that doesn't allow the response, such as
: array. PHP throws aTypeErroron that return before Drupal sees the response; - any other returned value;
- a
buildForm()on a class that isn't a form.
The method and the returned types are read from the wording of Mago's report. If Mago words the report differently, the report shows again rather than staying hidden.
Form arguments¶
FormBuilderInterface::getForm($form_arg, mixed ...$args) hands $args to the form's
buildForm($form, $form_state, ...$args). When the first argument names a form class, the analyzer
gives the call the parameters of that class's buildForm() after the form state, so Mago checks the
arguments as it does on a direct call. The reports have Mago's own codes, such as
too-many-arguments, too-few-arguments, invalid-argument, invalid-named-argument,
possibly-null-argument and mixed-argument, and name the form's method:
// buildForm(array $form, FormStateInterface $form_state, int $nid = 0)
$this->formBuilder->getForm('Drupal\x\Form\NodeIdForm', 'one'); // invalid-argument
$this->formBuilder->getForm('Drupal\x\Form\NodeIdForm', 1, 'extra'); // too-many-arguments
The first argument names the class as a fully qualified \Drupal\x\Form\Foo::class or as a
single-quoted class name, as in \Drupal::formBuilder()->getForm('Drupal\x\Form\Foo', $arg). The
class must be a concrete class that implements FormInterface, with every ancestor scanned.
Not checked, so the call keeps core's signature:
- a relative name such as
Foo::class. Mago asks for the signature before it analyzes the arguments, and the request has the text of the call but not theuseimports of the file. The name could be an import alias for a different class, even when only one scanned class has that short name; - a first argument that is
self::class,static::class, a double-quoted string, computed, or an object; - a call that unpacks an argument;
- a call to a form whose
buildForm()doesn't take anything after the form state. Such a form reads what it is passed from$form_state->getBuildInfo()['args'], if at all, as core'sThemeExperimentalConfirmFormdoes, and alter hooks can read it there too. Its calls take any number of arguments.
The count is checked as on a direct call. PHP drops extra arguments to a method, so
too-many-arguments marks an argument that the form never reads. The arguments are passed by
value, since they go through the variadic parameter of getForm(), and a named argument goes to the
buildForm() parameter of that name, as call_user_func_array() does. The types are the native
and docblock types of the parameters, so a wrong docblock is reported at the getForm() call, as it
would be on a direct call.
Render and form callbacks¶
Core runs the callbacks in a render or form array in four ways, and each one fails differently:
- The renderer passes
#pre_render,#post_render,#lazy_builderand#access_callbackthrough the callable resolver anddoTrustedCallback(). That call throws anUntrustedCallbackExceptionfor a method that it doesn't trust. The date elements (#date_date_callbacks,#date_time_callbacks) and the component element (#propsAlter,#slotsAlter) calldoTrustedCallback()on a plain PHP callable. - The Form API passes
#validate,#submit,#element_validate,#process,#after_build,#entity_buildersand thecallbackof#ajaxthroughFormState::prepareCallback(), which turns'::method'into a method of the form object, and then through the callable resolver, which throws for anything that it can't call. #value_callbackruns only whenis_callable()accepts it. Otherwise the element's default value callback runs, without a warning.- The machine name element passes the
existsof#machine_nametocall_user_func(), and the managed file element calls each of#file_value_callbacksas it is. Neither resolves or trusts anything, and both throw for anything that PHP can't call.
The callback checks below report an error where core throws, and a warning on #value_callback,
where core silently skips the callback. They read the callbacks written under those keys:
- in an array literal, as in
'#submit' => ['::save']; - in an assignment or an append, as in
$form['actions']['submit']['#submit'][] = '::save'; - in
array_unshift()andarray_push()on the key; - in the literal lists that an
array_merge()takes; - in the
callbackof an#ajaxarray and theexistsof a#machine_namearray.
A callback counts when it names its target literally:
'::save' // a method of the form object
'mymodule_form_submit' // a function
'Drupal\mymodule\Helper::build' // a class method, a leading backslash allowed
[Helper::class, 'build'] // the same, also as ['Drupal\mymodule\Helper', 'build']
[static::class, 'build'] // self::class, __CLASS__, get_class($this) and
static::class . '::build' // get_called_class() work the same way
[$class, 'build'] // after $class = get_class($this), or any of the above
[$this, 'build'] // an object; [$object, 'build'] when its type is one class
[$class, 'build'] counts when the function assigns $class once, with =, and no foreach or
reference binds it. Otherwise the type of the variable decides, as for an object: a class-string
that names one class, such as a parameter declared class-string<Helper>, counts as that class or a
subclass.
Foo::class, self::class and a string name that exact class. static::class,
get_class($this), $this and an object may be a subclass at runtime, so the class and every
descendant in the codebase are asked, and one that has the method, or trusts it, keeps the callback
quiet.
Not checked:
- a
service:methodstring, since the container may return a decorator or a lazy proxy; - closures, first-class callables, variables and anything computed. A closure in a form is
drupal/form-closure-callback, and a value whose type can never be called isdrupal/invalid-callback; - a class not scanned by Mago, or one whose hierarchy couldn't be resolved;
- the
form_submitof a batch set._batch_next_set()checks it with a plainis_callable()before it resolves it, so a'::method'or a'Class::method'that names an instance method is silently skipped there; - test code (any file under a
testsdirectory) and hook documentation (*.api.phpfiles). Tests build broken callbacks on purpose to exercise core's errors.
The lint rule drupal/render-callback reports a
plain function name under the render, date and component keys that is written in an array literal.
drupal/unknown-callback¶
- Level: error, or warning on
#value_callback - Source: phpstan-drupal
- Ports: phpstan-drupal's
RenderCallbackRule(partly)
A callback that names a method or a function that doesn't exist throws when core runs it, or is
silently skipped on #value_callback.
Reports:
- a callback that names a method its class doesn't have. A method from a parent or a trait counts,
and a class with
__call()or__callStatic()has every method; - a
'::method'on a form key, written in a class that implementsFormInterface, when that class doesn't have such a method. The form object is the class that the callback is written in; - a
'::method'on any other key, wherever it is written. OnlyprepareCallback()turns'::method'into a method, so on other keys it doesn't name a class at all; - a plain function name on the form keys, on
#value_callback, onexistsand on#file_value_callbacks, when the function would be in the codebase if it existed. That is a name that starts with<module>_or_<module>_, after the module directory that the file is in, when no file of that module declares it. The module's procedural files and its.phpfiles outsidesrcare read from disk, so a function in a file that the run leaves out still counts.
Not reported:
- a
'::method'on a form key in a trait, a hook implementation, a form alter or any other class that doesn't implementFormInterface. The form object is some other class there; - a plain function name on the render, date and component keys.
doTrustedCallback()rejects every one of them, anddrupal/render-callbackreports those written in an array literal, such as'#pre_render' => ['my_function']. One appended or assigned to such a key isn't reported; - a function named after another module, since that module may not be in the run. That includes a
module whose longer name also starts the function's name, such as
foo_bar_submit()written infoowhile afoo_barmodule exists; - on the form keys, a name that the services files define, since the callable resolver runs a
service through its
__invoke(); - test code (any file under a
testsdirectory) and hook documentation (*.api.phpfiles).
Example
// Reported.
function example_build_form(array $form): array {
$form['#submit'][] = 'example_form_sumbit';
return $form;
}
function example_form_submit(array &$form): void {
}
// Fine.
function example_build_form(array $form): array {
$form['#submit'][] = 'example_form_submit';
return $form;
}
function example_form_submit(array &$form): void {
}
Compared with phpstan-drupal
- phpstan-drupal checks only the six render keys
#pre_render,#post_render,#access_callback,#lazy_builder,#date_date_callbacksand#date_time_callbacks. It doesn't check the form keys,#value_callback,exists,#file_value_callbacksor the component keys. - phpstan-drupal reads a callback only from an array literal, not from an assignment, an append,
array_unshift(),array_push()orarray_merge(). - phpstan-drupal resolves a
service:methodstring through the service map and checks the service's class. The check leaves it alone, since the container may return a decorator or a lazy proxy. - phpstan-drupal also reports in test code.
drupal/non-static-callback¶
- Level: error, or warning on
#value_callback - Source: PHP's rules for callables
PHP 8 rejects a static call to an instance method, so a callback written as one throws, or is
skipped on #value_callback.
Reports:
- an array written as a static call that names an instance method. Examples are
[static::class, 'method'],[Helper::class, 'method']and['Drupal\mymodule\Helper', 'method']; - a
'Class::method'string that names an instance method, on the date and component keys, which take a PHP callable, onexists, on#file_value_callbacksand on#value_callback.
Not reported:
- a
'Class::method'string that names an instance method on the render and form keys. The callable resolver creates an instance of the class for it, so the method runs; - test code (any file under a
testsdirectory) and hook documentation (*.api.phpfiles).
Example
// Reported.
namespace Drupal\example;
class ExampleElement {
public function build(): array {
return ['#type' => 'container', '#process' => [[static::class, 'process']]];
}
public function process(array $element): array {
return $element;
}
}
// Fine.
namespace Drupal\example;
class ExampleElement {
public function build(): array {
return ['#type' => 'container', '#process' => [[static::class, 'process']]];
}
public static function process(array $element): array {
return $element;
}
}
Compared with phpstan-drupal
phpstan-drupal's render callback rule reports such an array, since PHPStan doesn't find a static
call to an instance method callable. It reads only the six render keys #pre_render,
#post_render, #access_callback, #lazy_builder, #date_date_callbacks and
#date_time_callbacks, and only array literals. It doesn't check the form keys, the component
keys, exists, #file_value_callbacks or #value_callback.
drupal/non-public-callback¶
- Level: error, or warning on
#value_callback - Source: PHP's rules for callables
Core calls every callback from its own classes: the callable resolver, is_callable() in the form
builder, a callable parameter of the date and component elements, or the machine name and managed
file elements. They can't reach a protected or private method, so such a callback throws, or
is skipped on #value_callback.
Reports:
- a callback that names a protected or private method, a
'::method'of a form included.
Not reported:
- a callback on a class with
__call()or__callStatic(), since PHP sends the call there; - a callback that names
static::class,get_class($this),$thisor an object, when a subclass in the codebase makes the method public; - test code (any file under a
testsdirectory) and hook documentation (*.api.phpfiles).
Example
// Reported.
namespace Drupal\example\Form;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Form\FormStateInterface;
class ExampleForm implements FormInterface {
public function getFormId(): string {
return 'example_form';
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['#validate'][] = '::validateName';
return $form;
}
protected function validateName(array &$form, FormStateInterface $form_state): void {
}
public function validateForm(array &$form, FormStateInterface $form_state): void {
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
}
}
// Fine.
namespace Drupal\example\Form;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Form\FormStateInterface;
class ExampleForm implements FormInterface {
public function getFormId(): string {
return 'example_form';
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['#validate'][] = '::validateName';
return $form;
}
public function validateName(array &$form, FormStateInterface $form_state): void {
}
public function validateForm(array &$form, FormStateInterface $form_state): void {
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
}
}
Compared with phpstan-drupal
phpstan-drupal doesn't report it. Its render callback rule reports a callback not found callable by PHPStan, and PHPStan finds a protected or private method callable, from inside the class and from outside it.
drupal/untrusted-callback¶
- Level: error
- Source: phpstan-drupal
- Ports: phpstan-drupal's
RenderCallbackRule, the part that needs the class
phpstan-drupal reports a render callback not trusted by core.
Core's doTrustedCallback() throws an UntrustedCallbackException for it. Core trusts a method
when:
- the class implements the extra interface that the caller passes:
RenderCallbackInterfacefor the renderer's keys, and none for the date and component keys; - the class implements
TrustedCallbackInterface, and itstrustedCallbacks()lists the method; - the method has the
#[TrustedCallback]attribute. Core reads the attribute by reflection off the method that the class has, so an override that doesn't repeat it isn't trusted, seedrupal/trusted-callback-override.
The check reads trustedCallbacks() from its body. It understands return with a list of string
literals, parent::trustedCallbacks() or an array_merge() of those, and a variable set to one of
them, grown with $callbacks[] = 'name' and returned. Any other body counts as trusting every
method, since its result can't be known without running it. Core compares the names in the list
case-sensitively, and so does the check.
Reports:
- a callback under a render, date or component key that names a class method not trusted by core.
The render keys are
#pre_render,#post_render,#lazy_builderand#access_callback, the date keys#date_date_callbacksand#date_time_callbacks, and the component keys#propsAlterand#slotsAlter.
Not reported:
- a plain function name. Core rejects every one there, and
drupal/render-callbackreports those written in an array literal; - a callback that names
static::class,get_class($this),$thisor an object, when a subclass in the codebase trusts the method; - test code (any file under a
testsdirectory) and hook documentation (*.api.phpfiles).
Example
// Reported.
namespace Drupal\example;
class ExampleBuilder {
public function build(): array {
return ['#markup' => '', '#pre_render' => [[self::class, 'addClass']]];
}
public static function addClass(array $element): array {
return $element;
}
}
// Fine.
namespace Drupal\example;
use Drupal\Core\Security\Attribute\TrustedCallback;
class ExampleBuilder {
public function build(): array {
return ['#markup' => '', '#pre_render' => [[self::class, 'addClass']]];
}
#[TrustedCallback]
public static function addClass(array $element): array {
return $element;
}
}
Compared with phpstan-drupal
- phpstan-drupal trusts every method of a class that implements
TrustedCallbackInterface, whatever itstrustedCallbacks()lists. The check reads the list. - phpstan-drupal trusts a class that implements
RenderCallbackInterfaceon the date keys too, where core doesn't trust any extra interface. - phpstan-drupal doesn't check the component keys
#propsAlterand#slotsAlter. - phpstan-drupal reads a callback only from an array literal, not from an assignment, an append,
array_unshift(),array_push()orarray_merge(). - phpstan-drupal resolves a
service:methodstring through the service map and checks the service's class. The check leaves it alone. - phpstan-drupal also reports in test code.
drupal/form-closure-callback¶
- Level: error
- Source: phpstan-drupal
- Ports: phpstan-drupal's
RenderCallbackRule, the closure check
phpstan-drupal reports a closure under a render callback key in a form. Drupal serializes a form cached between requests, as for an AJAX rebuild, and PHP can't serialize a closure.
Reports:
- a closure, an arrow function or a first-class callable under a render or date key, written in a
class that implements
FormInterface. The keys are#pre_render,#post_render,#lazy_builder,#access_callback,#date_date_callbacksand#date_time_callbacks. A first-class callable looks like$this->build(...).
Not reported:
- a closure under the form API keys, such as
#submit, and under the component keys, as in phpstan-drupal's rule; - a closure written outside a class that implements
FormInterface, such as in a form alter hook; - test code (any file under a
testsdirectory) and hook documentation (*.api.phpfiles).
Example
// Reported.
namespace Drupal\example\Form;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Form\FormStateInterface;
class ExampleForm implements FormInterface {
public function getFormId(): string {
return 'example_form';
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['preview'] = [
'#markup' => '',
'#pre_render' => [fn (array $element): array => $element],
];
return $form;
}
public function validateForm(array &$form, FormStateInterface $form_state): void {
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
}
}
// Fine.
namespace Drupal\example\Form;
use Drupal\Core\Form\FormInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Security\Attribute\TrustedCallback;
class ExampleForm implements FormInterface {
public function getFormId(): string {
return 'example_form';
}
public function buildForm(array $form, FormStateInterface $form_state): array {
$form['preview'] = [
'#markup' => '',
'#pre_render' => [[static::class, 'preRenderPreview']],
];
return $form;
}
#[TrustedCallback]
public static function preRenderPreview(array $element): array {
return $element;
}
public function validateForm(array &$form, FormStateInterface $form_state): void {
}
public function submitForm(array &$form, FormStateInterface $form_state): void {
}
}
Compared with phpstan-drupal
phpstan-drupal reads a callback only from an array literal. The check also reads a closure
assigned or appended to the key, or added with array_unshift(), array_push() or
array_merge().
drupal/invalid-callback¶
- Level: error
- Source: phpstan-drupal
- Ports: phpstan-drupal's
RenderCallbackRule, the check of the value's type
phpstan-drupal reports a value under a render callback key whose type may not be callable. Core
hands such a value to doTrustedCallback(), which throws for anything that PHP can't call.
Reports:
- a value under a render, date or component key whose type PHP can never call: an int, a float, a
bool, null, an array that can't be a two-element pair, or an object without
__invoke(). Every member of a union must be one of them, as in?int.
The keys are #pre_render, #post_render, the first element of #lazy_builder,
#access_callback, #date_date_callbacks, #date_time_callbacks, #propsAlter and
#slotsAlter. An object counts when its class doesn't have __invoke(), and no descendant in the
codebase has it either, unless the class is final.
Not reported:
- a string, which may name a function or a method, and
mixed, which may be anything. So a callback read from config or passed in as a string stays quiet; - a closure, a
callable, an array that may be a pair, such as[$object, $method]orarray<int, string>, and an object of a class not scanned by Mago; - the callbacks that the other checks read, such as
[Helper::class, 'build']; - test code (any file under a
testsdirectory) and hook documentation (*.api.phpfiles).
Example
// Reported.
function example_build(?int $weight): array {
return [
'#markup' => '',
'#pre_render' => [$weight],
];
}
// Fine.
function example_build(callable $callback): array {
return [
'#markup' => '',
'#pre_render' => [$callback],
];
}
Compared with phpstan-drupal
- phpstan-drupal reports any value that may not be callable, so it also reports a
string, a?string,mixedand an array such asarray<int, string>. Core runs a string from config or from a parameter when it names a trusted callback, so the check leaves those alone. - phpstan-drupal reports
[$class, 'method']with astring $classas not callable. The check leaves it alone too. - phpstan-drupal reports an array that isn't a pair, such as
[Helper::class, 'build', 'extra'], under itsrenderCallback.nonCallableCallbackidentifier. The check reports it with the other values that can never be called. - phpstan-drupal reads a callback only from an array literal, and doesn't check the component
keys. The check also reads a value assigned or appended to the key, or added with
array_unshift(),array_push()orarray_merge().
drupal/trusted-callback-override¶
- Level: warning
- Source: Drupal's
#[TrustedCallback]attribute
PHP doesn't inherit attributes, and core reads #[TrustedCallback] by reflection off the method
that the class has. So a method that overrides a trusted callback without repeating the attribute
isn't trusted as a render callback.
Reports:
- a method that overrides one with
#[TrustedCallback], declared in a parent class or in a trait that a parent uses, when the override doesn't repeat the attribute. The report is a warning, since the method may never be a callback on the subclass.
Not reported:
- a class that implements
RenderCallbackInterface, which trusts all its methods as render callbacks; - a class whose
trustedCallbacks()lists the method, or can't be read.
The classes and traits that declare a method with the attribute are read from the PHP files of
core/lib, core/tests and the src directory of every module, profile and theme before the
analysis starts. The descendants of those classes are checked, and for a trait, the descendants of
the classes that use it.
Example
namespace Drupal\example;
use Drupal\Core\Security\Attribute\TrustedCallback;
class ExampleBase {
#[TrustedCallback]
public static function preRender(array $element): array {
return $element;
}
}
// Reported.
namespace Drupal\example;
class ExampleChild extends ExampleBase {
public static function preRender(array $element): array {
return $element;
}
}
// Fine.
namespace Drupal\example;
use Drupal\Core\Security\Attribute\TrustedCallback;
class ExampleChild extends ExampleBase {
#[TrustedCallback]
public static function preRender(array $element): array {
return $element;
}
}
Compared with phpstan-drupal
phpstan-drupal doesn't check overrides of a trusted callback.
drupal/cacheable-dependency¶
- Level: warning
- Source: phpstan-drupal
- Ports: phpstan-drupal's
CacheableDependencyRule
phpstan-drupal reports a cacheable dependency that can't have cacheability. Core treats a dependency
that doesn't implement CacheableDependencyInterface as uncacheable, so the object that it is added
to drops to max-age 0.
Reports:
- an
addCacheableDependency()call whose dependency is a scalar, an array, null, or a final class that doesn't implementCacheableDependencyInterface. The calls checked are those on aRefinableCacheableDependencyInterface, aCacheableResponseInterfaceor aContextInterface, which take the dependency first, and on aRendererInterface, which takes it second, as$dependency.
Not reported:
- a dependency typed as
mixed, a bareobject, an interface, a class not scanned by Mago, or a class that can be extended. The value passed may be a subclass that implements the interface, as core'sCacheableHttpExceptiondoes.
Example
// Reported.
use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Config\ImmutableConfig;
function example_add_site(RefinableCacheableDependencyInterface $metadata, ImmutableConfig $config): void {
$metadata->addCacheableDependency((string) $config->get('name'));
}
// Fine.
use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Config\ImmutableConfig;
function example_add_site(RefinableCacheableDependencyInterface $metadata, ImmutableConfig $config): void {
$metadata->addCacheableDependency($config);
}
Compared with phpstan-drupal
phpstan-drupal reads the renderer's dependency as its second argument by position, so a call
that names its arguments in another order is checked against the render array. The check
matches the dependency: name.