Problem/Motivation
Deprecated function: strnatcasecmp(): Passing null to parameter #2 ($string2) of type string is deprecated in Drupal\Core\Layout\LayoutPluginManager->Drupal\Core\Layout\{closure}() (line 204 of core/lib/Drupal/Core/Layout/LayoutPluginManager.php).
appears on pages, where layouts are being listed in a select which uses $this->layoutPluginManager->getLayoutOptions()
to get its options.
Example:
$form['field_layouts']['field_layout'] = [
'#type' => 'select',
'#title' => $this->t('Select a layout'),
'#options' => $this->layoutPluginManager->getLayoutOptions(),
'#default_value' => $layout_plugin->getPluginId(),
'#ajax' => [
'callback' => '::settingsAjax',
'wrapper' => 'field-layout-settings-wrapper',
'trigger_as' => ['name' => 'field_layout_change'],
],
];
(FieldLayoutEntityDisplayFormTrait.php)
layout_builder's layout_builder_blank
aka BlankLayout neither has a label nor a category defined in its annotation:
/**
* Provides a layout plugin that produces no output.
*
* @see \Drupal\layout_builder\Field\LayoutSectionItemList::removeSection()
* @see \Drupal\layout_builder\SectionListTrait::addBlankSection()
* @see \Drupal\layout_builder\SectionListTrait::hasBlankSection()
*
* @internal
* This layout plugin is intended for internal use by Layout Builder only.
*
* @Layout(
* id = "layout_builder_blank",
* )
*/
class BlankLayout extends LayoutDefault {
it should be sorted out by
/**
* Implements hook_plugin_filter_TYPE_alter().
*/
function layout_builder_plugin_filter_layout_alter(array &$definitions, array $extra, $consumer) {
// Hide the blank layout plugin from listings.
unset($definitions['layout_builder_blank']);
}
(layout_builder.module)
but that hook never gets called!
So the layout_builder_blank
layout is being passed through without label or category, but these methods rely on these values to be present. They are being called indirectly by $this->layoutPluginManager->getLayoutOptions()
/**
* {@inheritdoc}
*
* @return \Drupal\Core\Layout\LayoutDefinition[]
*/
public function getSortedDefinitions(array $definitions = NULL, $label_key = 'label') {
// Sort the plugins first by category, then by label.
$definitions = $definitions ?? $this->getDefinitions();
uasort($definitions, function (LayoutDefinition $a, LayoutDefinition $b) {
if ($a->getCategory() != $b->getCategory()) {
return strnatcasecmp($a->getCategory(), $b->getCategory());
}
return strnatcasecmp($a->getLabel(), $b->getLabel());
});
return $definitions;
}
/**
* {@inheritdoc}
*
* @return \Drupal\Core\Layout\LayoutDefinition[][]
*/
public function getGroupedDefinitions(array $definitions = NULL, $label_key = 'label') {
$definitions = $this->getSortedDefinitions($definitions ?? $this->getDefinitions(), $label_key);
$grouped_definitions = [];
foreach ($definitions as $id => $definition) {
$grouped_definitions[(string) $definition->getCategory()][$id] = $definition;
}
return $grouped_definitions;
}
/**
* {@inheritdoc}
*/
public function getLayoutOptions() {
$layout_options = [];
foreach ($this->getGroupedDefinitions() as $category => $layout_definitions) {
foreach ($layout_definitions as $name => $layout_definition) {
$layout_options[$category][$name] = $layout_definition->getLabel();
}
}
return $layout_options;
}
This is why the error appears.
This deprecation warning didn't appear in older versions of PHP, so the bug wasn't visible.
TL;DR:
/**
* Implements hook_plugin_filter_TYPE_alter().
*/
function layout_builder_plugin_filter_layout_alter(array &$definitions, array $extra, $consumer) {
// Hide the blank layout plugin from listings.
unset($definitions['layout_builder_blank']);
}
(layout_builder.module)
is not called, so the layout_builder_blank
layout, which neither has a label nor a description is not being sorted out, which leads to this error!
Steps to reproduce
- Install Drupal with the Umami demo profile.
- Enable the
field_layout
module. - Log in as an administrator.
- Visit
/en/admin/structure/types/manage/article/form-display
.
Alternatively, install Drupal with the Standard profile, then enable the field_layout
and layout_builder
modules.
Further analysis
It seems as like the LayoutPluginManager (DI-injected by $container->get('plugin.manager.core.layout'),
) is entirely missing FilteredPluginManagerTrait
, so that its alterations are never being called:
/**
* Implements \Drupal\Core\Plugin\FilteredPluginManagerInterface::getFilteredDefinitions().
*/
public function getFilteredDefinitions($consumer, $contexts = NULL, array $extra = []) {
if (!is_null($contexts)) {
$definitions = $this->getDefinitionsForContexts($contexts);
}
else {
$definitions = $this->getDefinitions();
}
$type = $this->getType();
$hooks = [];
$hooks[] = "plugin_filter_{$type}";
$hooks[] = "plugin_filter_{$type}__{$consumer}";
$this->moduleHandler()->alter($hooks, $definitions, $extra, $consumer);
$this->themeManager()->alter($hooks, $definitions, $extra, $consumer);
return $definitions;
}
meanwhile, they are being called in Layout builder (e.g. admin/structure/types/manage/page/display/default/layout) when adding a section.
So maybe layout_builder is doing something different or special?
If layout_builder is not installed, everything is fine!
Proposed resolution
- Add missing attributes to
Drupal\layout_builder\Plugin\Layout\BlankLayout
so that getCategory()
and getLabel()
return string|\Drupal\Core\StringTranslation\TranslatableMarkup
as required, instead of NULL
. - Call
getFilteredDefinitions()
in LayoutPluginManager::getLayoutOptions()
. - Add test coverage for (2).
Either (1) or (2) by itself is enough to avoid the error. If we implement (1) but not (2), then it leads to a regression: the blank layout is listed, when it currently is not. See Comment #50. If we implement (2) but not (1), then we are hiding the error instead of fixing it.
For (3), mock Drupal::service('theme.manager')
, since that is called in FilteredPluginManagerTrait::themeManager()
. Then call getLayoutOptions()
and confirm that the module and theme alter()
methods are called.
Other approaches:
- Add an optional parameter to
LayoutPluginManager::getLayoutOptions()
and pass it to getFilteredDefinitions()
, using $this->getType()
as a fallback. - Make
$consumer
optional in getFilteredDefinitions()
, and invoke just the first hook if it is not provided.
Remaining tasks
Decide whether to implement one of the other approaches in this issue or in a follow-up.
User interface changes
None, except for not showing the PHP error.
API changes
To be determined.
Data model changes
None
Release notes snippet
N/A