Upgrading extensions to symfonic Drupal

by Gisle Hannemyr

This chapter describes how to go about upgrading extensions to the next major version of symfonic Drupal.

Table of contents

Drupal projects discussed in this chapter: Drupal Module Upgrader, Rector, Upgrade Status.

Introduction

This chapter is about upgrading Drupal extensions (modules and themes maintained by yourself (first party extensions).

It is a collection of hints and best practices when transforming Drupal 7 code into symfonic Drupal, and also covers how to discover and deal with deprecations between major version of Drupal.

External links to the Drupal.org ecosystem and Stack Exchange are prefixed as follows:

https://www.drupal.org/about/drupal-7/d7eol/partners

DMU

The DMU is a drush extension to help upgrading contributed modules.

It is a script that scans the source of a Drupal 7 module, flags any code that requires updating to symfonic Drupal, points off to any relevant API change notices from www.drupal.org/list-changes, and (where possible) will actually attempt to convert the Drupal 7 code automatically to the symfonic Drupal version.

These are the steps to install and enable the module on a Drupal 8 and later site. The site should already have been set up to be managed by composer:

Run the following pair of commands in the siteroot:

$ composer require drupal/drupalmoduleupgrader
$ drush en drupalmoduleupgrader -y

It has two different modes:

  1. Analyze: Show api changes with links to API change records on Drupal.org.
  2. Upgrade: Try to do code upgrades in place.

You use it from the CLI, below a symfonic Drupal website:

$ drush dmu-analyze mymodule
$ drush dmu-upgrade mymodule

To use it, place the Drupal 7 module you wish to upgrade into your symfonic Drupal site's /modules directory and run one of the commands above.

You may re-run “dmu-analyze” to see remaining problems with a partially upgraded site.

The analyze mode produces a report named “upgrade-info.html” in the root directory of the extenions you're upgrading. To work with it, add a shortcut link link to the report for the project you are currently upgrading. Example:

The upgrade mode will output a few lines as it attempts various conversions. Note that it is far from complete. It will comment out a lot of code chunks and add the phrase “@FIXIT” if it is not capable of upgrading a chunk.

Tweaks

Do not remove the legacy mymodule.info. As long as it stays in place, you shall be able to re-run “dmu-analyze”. Instead make sure the “core” and “type” keys are set to these values to silence bogus warnings from the analyzer.

core = 8.x
type = module

The upgrader is far from perfect. Here is a list of tweaks that is usually required after running dmu-upgrade.

  1. Upgrade hook_help().
  2. Remove legacy code.
  3. Fix hook_uninstall().

These bullet points are expanded below.

Rector

A contributed Drupaø module named Rector may save a lot of manual work by automating code upgrades from Drupal 8 and later to Drupal 9.

$ composer require --dev palantirnet/drupal-rector

Palantir.net: Jumpstart Your Drupal 9 Upgrade with Drupal Rector

Upgrade Status

Drupal-check

Implement PSR-4 Autoloading

X CR: X

Upgrade Variables

Global variables

Several Drupal 7 global values such as:

global $language 
global $user

are gone and must be accessed via services in symfonic Drupal:

Drupal::languageManager()->getCurrentLanguage()
Drupal::currentUser()

Persistent variables

The variable_*-functions are gone.

CR: The variable_get/set/del API is now removed.

Persistent variables now lives in the configuration system:

config/install/modulename.settings.yml
config/schema/modulename.schema.yml

The name and initial value of persistent variables are set in modulename.settings.yml, while modulename.schema.yml is used for XXXX

Below are some examples pulled from the Notify module. A persistent variable named notify_period is defined in notify.settings.yml:

notify_period: '86400'

symfonic Drupal supports a Kwalify-inspired (see: www.kuwata-lab.com/kwalify) schema/metadata language for configuration YAML files.

DO: Configuration schema/metadata.

The example below is extracted from the Notify notify.schema.yml:

notify.settings:
  type: config_object
  label: 'E-mail notification settings'
  mapping:
    notify_period:
      type: string
      label: 'Send notifications every'

The top-level key (notify.settings) refers to the base filename of the .yml file (notify.settings.yml) and to the name of the configuration object (config.notify.settings)).

Schema files were introduced in Drupal 8 for multilingual support.

To inspect the state of a persistent variable:

  $notify_period = $config->get('notify_period');

To save these persistent variables after they've been altered and the form with the new values, use the following construct:

 
class SettingsForm extends ConfigFormBase {

  public function submitForm(array &$form, FormStateInterface $form_state) {
    $values = $form_state->getValues();
    $this->config('notify.settings')
      ->set('notify_period', $values['notify_period'])
      &hellip:
      ->save();
    $this->messenger->addMessage($this->t('Notify admin settings saved.'));
  }
}

See alsoDO: Simple Configuration API.

Database variables

In the line pairs below, the first line is D7 code, and the second is what to use for D8/D9.

Database variables that belongs to the project are automatically upgraded like this:

$sender = variable_get('testmail_sender', '');
$sender = \Drupal::config('testmail.settings')->get('testmail_sender');

Database variables to come from other modules are commented out like this:

// @FIXME
// This looks like another module's variable. You'll need to rewrite this call
// to ensure that it uses the correct configuration object.
// $sitemail = variable_get('site_mail', '');
 

Site name:

$sitename = variable_get('site_name', 'Drupal');
$sitename = \Drupal::config('system.site')->get('name');

Site mail:

$sitemail = variable_get('site_mail', '');
$sitemail = \Drupal::config('system.site')->get('mail');

Replace invalid placeholder

The placeholder !variable is gone. Using it will trigger this error:

User error: Invalid placeholder (!variable) in string: The !variable.

Replace with placeholder @variable or concatenate directly.

API: protected static function FormattableMarkup::placeholderFormat.

Remove legacy code

In Drupal 7, the data entry forms will typically originate from hook_menu() in the main module with the form definition functions and the form processing functions (validate, submit) placed in a separate file named mymodule.admin.inc.

The dmu-upgrade-script will replace all of this with class MymoduleAdminSettings(), located in src/Form/MymoduleAdminSettings.php, with public functions buildForm() and submitForm(). The old legacy functions mentioned above should be removed from the repo.

The same goes for hook_permissions the upgrader moves these to a MYMODULE.permissions.yml, and hook_permissions can be deleted.

See alsoFor more on permissions, see this page: Acquia: Drupal permissions. Also see the CR.

Working with routes

mymodule.routing.yml

See chapter Link API.

Replace deprecated functions

Replace drupal_goto()

DOF: Redirect to front page.

CR: drupal_goto() has been removed.

Replace drupal_mail()

The D7 function drupal_mail() has been replaced by the MailManagerInterface::mail() method.

Both calls hook_mail(). The parameters are almost the same, with $from being replaced by $replyto. Example parameters are set up by assigning variables. For optional parameters, the defult is shown in a comments for the variables.

$mailManager = \Drupal::service('plugin.manager.mail');
$module   = 'mymodule';
$key      = 'send';
$to       = 'user@example.com';
$langcode = 'en';
$params   = [];                 // []
$from     = 'admin@example.com' // NULL;
$replyto  = 'admin@example.com' // NULL;
$send     = TRUE;               // TRUE
       drupal_mail($module, $key, $to, $langcode, $params, $from,    $send);
$mailManager->mail($module, $key, $to, $langcode, $params, $replyto, $send);

Replace drupal_set_message()

-drupal_set_message(t('text'));
+\Drupal::messenger()->addMessage(t('text'));

This is done automatically the dmu-upgrade.

These exist:

Replace l() and url()

The Drupal 7 functions l() (to generate a link to an internl path) and url() (for linking to external URLs) are deprecated.

CR: l() and url() are removed in favor of a routing based URL generation API.

// Internal path in Drupal 7.
$internal_link = l(t('Book admin'), 'admin/structure/book');

Becomes in symfonic Drupal:

// Internal path (defined by a route in symfonic Drupal).
use Drupal\Core\Link;
use Drupal\Core\Url;
$url = Url::fromRoute('book.admin');
$internal_link = Link::fromTextAndUrl(t('Book admin'), $url)->toString();

To generate a link to an external URL in symfonic Drupal:

$please_visit = t('Please visit %externalurl soon.', [
  '%externalurl' => Link::fromTextAndUrl(t('example.com'),
    Url::fromUri('https://example.com'))->toString(),
]);

A route may have arguments. These may be added by means of an arry. This example is from my adaption the Tardis module, where the path my include a year and a month:

$url = Url::fromRoute('view.tardis.tardis_page',
  ['arg_0' => '2021', 'arg_1' => '07'])->toString();

Another example:

use Drupal\Core\Link;
use Drupal\Core\Url;
$link1 = Link::fromTextAndUrl(t('mylink'), Url::fromRoute('user.login'))->toString();
dpm(htmlspecialchars($link1), '$link1');
$link2 = Link::createFromRoute('the link title', 'user.login')->toString()->getGeneratedLink();
dpm(htmlspecialchars($link1), '$link2');

For a link enclosed in translatable text, Drupal core suggests to use t() and embed the HTML anchor tag directly in the translated string. In LinkGeneratorInterface::generate(), the example code for this case is the following.

$text = t('Visit the <a href=":url">content types</a> page', array(
  ':url' => Url::fromRoute('entity.node_type.collection')->toString(),
));

$text = t('It can visited on the path  /tardis/YYYY/MM.', array(
  ':url' => 
));

Replace $file>url()

$element['attributes']['url'] = $file->url();

becomes:

$element['attributes']['url'] = $file->createFileUrl(FALSE);

CR: $file->url() returning the URL to the physical file is deprecated.

Replace node_view()

-$content = node_view($node, 'full');
+$content =\Drupal::entityTypeManager()->getViewBuilder('node')->view($node, 'full');

Replace form_set_error()

form_set_error('rows][r1][c1', t('Field cannot be empty.'));
$form_state->setErrorByName($name, $message);
$form_state->setErrorByName('placeholder, 'Field cannot be empty.');
$form_state->setErrorByName("housename","The housename field is empty");
API

Upgrade major hooks

Upgrade hook_help()

The signature of hook_help has changed:

D7: hook_help($path, $arg)
D8: hook_help($route_name, RouteMatchInterface $route_match)

The first parameter is no longer a path. Instead, it is the name of a route. For page-specific help, use the route name as identified in the module's .routing.yml-file. For module overview help, the route name will be help.page.modulename. Example:

// Create an alias RouteMatchInterface.
use Drupal\Core\Routing\RouteMatchInterface;

/**
 * Implements hook_help().
 */
function mymodule_help($route_name, RouteMatchInterface $route_match) {
  switch ($route_name) {
    case 'help.page.mymodule':
      $output = '<p>' . t('Some help text') . '</p>';
      return $output;
  }
}

When converting D7 module overview help you just need to replace the signature and the path admin/help#modulename in the switch with the route help.page.modulename.

tipLeaving out the "use" statement produces the following error: "TypeError: Argument 2 passed to mymodule_help() must be an instance of RouteMatchInterface, instance of Drupal\Core\Routing\CurrentRouteMatch given in mymodule_help()".

To embed link to an internal path in the help text, use the following format:

$output = t('Navigate to <a href=":path">/mymodule</a>.', [
  ':path' => Url::fromRoute('mymodule.form')->toString(),
]);

To embed a link to an external URL the help text, use the following format:

$output = t('Visit <a href=":url">Example.org</a>.', [
  ':url' => Url::fromUri('https://example.org')->toString(),
]);

See also: Replace l() and url()

Upgrade hook_uninstall()

In hook_uninstall(), dmu-upgrade will convert D7 variable_del() function calls like the one below into the first D8 call seen below. This will trigger an ImmutableConfigException when you uninstall. The second D8 call is the one to use.

variable_del('mymodule_conf'); // D7
\Drupal::config('mymodule.settings')
  ->clear('mymodule_conf')->save(); // D8 Wrong
\Drupal::configFactory()->getEditable('mymodule.settings')
  ->clear('mymodule_conf')->save(); // D8 right

Source: Drupal.org: Can't uninstall.

See alsoSee also: Cloudways.com: A Guide for Upgrading a Drupal 7 Website to Drupal 8.

Upgrade hook_ET_view_alter()

node_ENTITY_TYPE_view_alter(&$build)
hook_ENTITY_TYPE_view_alter(array &$build,
  \Drupal\Core\Entity\EntityInterface $entity,
  \Drupal\Core\Entity\Display\EntityViewDisplayInterface $display)

Upgrade hook_ET_insert()

This hook responds to creation of a new entity of a particular type and runs once the entity has been stored.

Example: Add the nid and cid of newly created unpublished comments to the unpublished queue table:

/**
 * Implementation of hook_comment_insert().
 */
function notify_comment_insert(Drupal\Core\Entity\EntityInterface $entity) {
  if (!$entity->status->value) {
    \Drupal::database()->insert('notify_unpublished_queue')
      ->fields([
        'nid' => $entity->get('entity_id')->getValue()[0]['target_id'],
        'cid' => $entity->id(),
      ])
      ->execute();
  }
}

See alsoSee also: SE: How to get comment cid in hook_comment_insert?, How to get the node id the comment is attached to in hook_comment_insert?. API.

Fields

In Drupal 7, the Fields API was frequently used to create custom content types programmatically. In symfonic Drupal content types are configuration objects, and may be created in the administrative GUI, and exported as cinfiguration files. See the chapter Creating custom content types for more.

// @FIXME
// Fields and field instances are now exportable configuration entities, and
// the Field Info API has been removed.
$field_definition = array(
  'field_name' => 'field_description',
  'type' => 'text',
);  
field_create_field($field_definition);
$field_storage = FieldStorageConfig::create(array(
  'name' => 'field_description',
  'entity_type' => 'content',
  'type' => 'text',
));
$field_storage->save();
An introduction to entities

CR: Field and field instance are now configuration entities

Upgrade collapsible

Collapsible fieldsets have been replaced with the HTML5 details element.

Drupal 7:

$form['advanced'] = array(
  '#type' => 'fieldset',
  '#title' => t('Advanced settings'),
  '#collapsible' => TRUE,
  '#collapsed' => FALSE,
  '#description' => t('Lorem ipsum.'),
);

Symfonic Drupal:

$form['advanced'] = array(
  '#type' => 'details',
  '#title' => t('Advanced settings'),
  '#description' => t('Lorem ipsum.'),
  '#open' => TRUE, // Controls the HTML5 'open' attribute. Defaults to FALSE.
);

The fieldset type is still around, but it is no longer collapsible. Use it to group fields that are semantically related (e.g. a date fieldset with year, month, day, time).

See alsoSee also: CR: All collapsible fieldsets have been replaced with HTML5 details elements. DO: Details type.

Pending:

Runtime errors

When trying to insert values into the database, e.g:

\Drupal::database()->insert('notify_unpublished_queue')
  ->fields([
    'nid' => $nid,
    'cid' => $cid,
  ])
  ->execute();

… and you get:

Drupal\Core\Entity\EntityStorageException: Placeholders must have a trailing [] if they are to be expanded with an array of values.

Meaning: database expects $nid to be int (scalar) $cid, but one of them is an array.

PHPStan errors

Unsafe usage of new static()

This error is reported for new static() calls that might break once the class is extended, and the constructor is overridden with different parameters.

If you do not intend the class to be extended make it final:

final class MyClass {
    …
}

Once you make the class final, the error is no no onger reported.

See alsoOn PHPStan.org, this page: Solving PHPStan error "Unsafe usage of new static()", provides several other alternatives for resolving this error.

Dependency injection

Dependency injection is the preferred method for accessing and using services in Drupal 10 and should be used in object oriented contexts. Rather than calling out to the global services container (\Drupal), services are instead injeted as arguments to a class constructor.

PHPStan will report calls to the global services container with this error:

\Drupal calls should be avoided in classes, use dependency injection instead

In the following example, the entity_type.manager service is converted to use DI.

First make sure there is a use-statement declaring its interface at the start of the file:

use Drupal\Core\Entity\EntityTypeManagerInterface;

To discover what interface to use, you shouls often be able to examine the class in the Drupal API. I.e.: DOA class EntityTypeManager. There you'll find the name of interface to use:

class \Drupal\Core\Entity\EntityTypeManager implements
  \Drupal\Core\Entity\EntityTypeManagerInterface

If that doesn't work serach for uses of the service in the Drupal core, and use the pattern found as an example.

If the class is a service, and it is injecting other services, make sure that the injected service is declared as an argument in notify.services.yml:

services:
  notify:
    class: Drupal\notify\Notify
    arguments: ['…, '@entity_type.manager', …]

Make sure you rebuild the caches to make this change discovered.

Declare the property like this:

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

Also, ensure that you inialized it in the class' constructor. It needs to be defined using the @param in the comment block, and as an argument to the __construct function:

  /**
   * Constructs a new Notify object.
   *
   …
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
   */
  public function __construct(…, EntityTypeManagerInterface $entity_type_manager) {
    …
    $this->entityTypeManager = $entity_type_manager;
  }

If there is a static create functon, make sure it returns the same number of arguments as is declared for the contructor. The name must be the same name as the one declared for the service:

  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('config.factory'),
      $container->get('messenger'),
      $container->get('module_handler'),
      $container->get('entity_type.manager')
    );
  }

Now, you should be able to replace static call:

$node = \Drupal::entityTypeManager()->getStorage('node')->load($pnid);

with:

$node = $this->entityTypeManager->getStorage('node')->load($pnid);

Background:

Services invoked by means of the global service container

Some services may be invoked by means of the global \Drupal::service container:

$nodep = \Drupal::service('module_handler')->moduleExists('node');

To inject one of these, inject the actual service:

use Drupal\Core\Extension\ModuleHandlerInterface;
…
$nodep = $this->moduleHandler->moduleExists('node');

Not only Drupal services may be invoked this way. This example shows a static call the request stack service provided by Symfony:

$request = \Drupal::service('request_stack')->getCurrentRequest();

Making use of the injected service:

use Symfony\Component\HttpFoundation\RequestStack;
…
$request = $this->requestStack->getCurrentRequest();

Injecting the translation service

In object-oriented code Drupal (i.e. controllers, listeners, plugins, etc.), you should use dependency injection to obtain the translation service, define methods t() and formatPlural() on your class.

To do so, use StringTranslationTrait:

use Drupal\Core\StringTranslation\StringTranslationTrait;

And in the class:

class MyClass {
  use StringTranslationTrait;

Now you can use:

$this->t()
$this->formatPlural()

Background:

Final word

[mumble]


Last update: 2021-03-23 [gh].