Adventures in Porting a D7 Form Module to Drupal 8

Got some Drupal 7 modules that use the Form API lying around? Want to learn how to port them to Drupal 8? The process could just be the crash course you've been looking for to learn Drupal 8 object-oriented module development and Drupal 8's Form API.

Back in the day, Lullabot trainers produced a module to demonstrate how to use Drupal's Form API. The module was called Form Fun and today, I'm going to tell you a little bit about the "fun" I had porting the Drupal 7 version of this module to Drupal 8.

In this blog post, you'll learn:

  • How to use Drupal Module Upgrader to upgrade a D7 module
  • How hook_menu was replaced with routes and controllers
  • How to find information about API changes in Drupal 8

To get a jumpstart on porting this Drupal 7 module to Drupal 8, I decided to give the Drupal Module Upgrader module a try. I saw a demonstration of it by Angie Byron at the Pacific Northwest Drupal Summit in October and I have been determined to try it out ever since.

Getting Ready to Port

To start, I created a Drupal 8 site on my local machine. At the time, this was Drupal 8, Beta 3. For information about how to install Drupal 8, read this Installation Guide.

I also have Drush 7 installed. You will need this, too, if you want to use the Drupal Module Upgrader. In Terminal, or your command-line interface of choice, type drush --version to see which version of Drush you have installed. If it returns 7.0-dev, you can move on. (Likely any 7.x version will work.) But if you find you need to upgrade Drush, check out my blog post on Upgrading Drush to Work with Drupal 8 for some tips.

Both Drush 7 and Drupal Module Upgrader require Composer. If you are brand new to Composer and would like to learn about what the heck it is, check out this free video on Drupalize.Me from our partners at KnpUniversity: The Wonderful World of Composer. If you don't remember if you've installed Composer already, if you're on a Mac, fire up Terminal and type which composer. If a path is returned, then you're all set. If you need to get Composer, see this doc page.

Drupal Module Upgrader

I recommend installing the latest dev version of Drupal Module Upgrader (DMU). I ran into some problems just installing the stable version which were fixed in the latest dev. So, save yourself some troubleshooting and go ahead and install the latest dev using drush by running this command in your command-line interface (CLI): drush dl drupalmoduleupgrader --select and select [1]. For this tutorial, I downloaded the 8.x-1.x-dev | 2014-Nov-27 | Dev version.)

Follow the instructions in Drupal Module Upgrader's README.txt file. If you run into errors with any of the dependencies that Composer is updating, like Pharborist, try running composer update in the /modules/drupalmoduleupgrader directory. For other troubleshooting help, search the Drupal Module Upgrader Issue Queue.

Now we're almost ready to port this thing, but first we need to copy the D7 module into the modules/ directory.

Back in your CLI, with the DMU module installed, we now have some new Drush commands. Run drush help --filter=drupalmoduleupgrader to see the shiny new commands provided by DMU module.

Analyzing the API Changes

Now run drush dmu-analyze. This command creates an HTML file in your module's directory with a breakdown of every API change that affects your module with links to the change records. (Note: I discovered that this page renders much better in Chrome than in Firefox, on a Mac anyway, so take that for what it's worth.) Right away, I can see that form_set_error() is now a method of FormStateInterface. but there are many more changes listed, with links to the change records containing more information about the API change.

form_set_error() is now a method of FormStateInterface.

Running the Upgrade

After perusing the changes that will need to be made, we're about ready to run the upgrade process. But first, make sure you've got a backup of your module before proceeding.

My module's machine name is form_fun. To have DMU attempt to upgrade it to D7, I run drush dmu-upgrade form_fun (replace "form_fun" with the machine name of your module, if you're playing along).

In the case of my module, it was a bit anti-climatic. But, navigating to modules/form_fun, I can see a bunch of new files! I'm excited! (Or maybe I should be afraid!?)

More Than Just Form API Changes

Even though my module was all about demonstrating Drupal 7's Form API, it used other Drupal 7 APIs and function calls to create menu links, URLs, and page content. This means that in this process of porting this D7 module to D8 using Drupal Module Upgrader, I'm also learning about how Drupal 8 replaced hook_menu and other functions. I'm also getting a crash course on object-oriented PHP and using controllers, classes, and methods instead of as a bunch of procedural functions inside .inc files.

Hook_Menu's New Split Personality

hook_menu has been removed from Drupal 8

hook_menu has been removed from Drupal and in its place, routes and controllers. A route defines what happens at a certain URL and a controller determines what content or functionality should be found at a certain URL.

So, for purposes of comparison, here's a snippet of the D7 version of Form Fun's hook_menu implementation that defines pages, their paths, access, files, functions or "page callbacks", related pages, and in what order those sub-pages should be listed in a menu:

D7 example usage of hook_menu

/**
 * A super-simple menu hook that tells Drupal about our Fun Module's page.
 */
function form_fun_menu() {

  $items['form_fun'] = array(
    'title' => 'Fun with FormAPI',
    'page callback' => 'form_fun_page',
    'access arguments' => array('access content'),
  );

  $items['form_fun/cake'] = array(
    'title' => 'Death, or cake?',
    'page callback' => 'form_fun_cake_page',
    'access arguments' => array('access content'),
    'file' => 'form_fun.cake.inc',
    'weight' => 1,
  );

  $items['form_fun/existential'] = array(
    'title' => 'Existential questions',
    'page callback' => 'drupal_get_form',
    'page arguments' => array('form_fun_existential'),
    'access arguments' => array('access content'),
    'file' => 'form_fun.existential.inc',
    'weight' => 2,
  );

...

  /**
   * These menu callbacks should be ignored! They're secret. Suuuuuuper secret.
   */

  $items['form_fun/death_image'] = array(
    'page callback' => 'form_fun_death_image',
    'access callback' => TRUE,
    'file' => 'form_fun.cake.inc',
    'type' => MENU_CALLBACK,
  );

...

  return $items;
}

In the Drupal 8 version of Form Fun, created by DMU, the functionality in the D7 Form Fun hook_menu has now been placed in the following three files:

  • modules/form_fun/form_fun.routing.yml
  • modules/form_fun/form_fun.links.menu.yml
  • modules/form_fun/src/Controller/DefaultController.php

So now if you wanted to tell Drupal to do something at a certain URL, like build a page, display a form, or create some menu links programmatically, you will need a YAML file (perhaps more than one) and potentially a Controller php file. In the case of form_fun, DMU created src/DefaultController.php to output content for one of the pages in the module, created form_fun.routing.yml to tell Drupal about the names of the "routes" declared in this module, and form_fun.links.menu.yml to tell Drupal about how a menu of links should be built for this module.

Defining Routes with YAML

The form_fun.routing.yml file is a YAML file that describes route names, like form_fun.page. It describes any necessary related information like the path, title, what type of page it is, and where to find the method that builds whatever it is. That could be a controller definining page content, or a class handing a form. Think of the structure of a YAML file as an array, but with colons instead of commas and meaningful indentations.

Here's a snippet of the form_fun.routing.yml file created by DMU in the upgrade process:

form_fun.routing.yml (snippet)

form_fun.page:
  path: /form_fun
  defaults:
    _title: 'Fun with FormAPI'
    _content: '\Drupal\form_fun\Controller\DefaultController::form_fun_page'
  requirements:
    _permission: 'access content'
form_fun.cake_page:
  path: /form_fun/cake
  defaults:
    _title: 'Death, or cake?'
    _content: '\Drupal\form_fun\Controller\DefaultController::form_fun_cake_page'
  requirements:
    _permission: 'access content'
form_fun.existential:
  path: /form_fun/existential
  defaults:
    _title: 'Existential questions'
    _form: \Drupal\form_fun\Form\FormFunExistential
  requirements:
    _permission: 'access content'
 

Note that in Beta 4 of Drupal, you won't have to specify that a route uses a _content controller. You will just need to specify _controller. (See this change record: Routes use _controller instead of _content.)

As you can see, if a route is a form, you specify a _form key with the value being the name of the class that defines your form and its workflow.

Defining Menu Links for the Module

There's now a separate place for defining menu links in your modue. In my Form Fun module, these are contained in the new form_fun.links.menu.yml file created by DMU. Notice the parent and weight keys that create a hierarchy and order menu links.

form_fun.links.menu.yml (snippet)

form_fun.page:
  route_name: form_fun.page
  title: 'Fun with FormAPI'
form_fun.cake_page:
  route_name: form_fun.cake_page
  title: 'Death, or cake?'
  weight: 1
  parent: form_fun.page
form_fun.existential:
  route_name: form_fun.existential
  title: 'Existential questions'
  weight: 2
  parent: form_fun.page

Controllers

Finally, the Controller. A Controller is the place to put methods that output some content to the page. It replaces the page callback procedural function. In the case of the Form Fun module, it's the page that defines a method that returns a list of links to the other forms. It's also the home of several methods that output some images to certain pages. These pages will serve as redirect locations in one of Form Fun's forms. We'll even output a form to a page in a Controller, replacing Drupal 7's drupal_get_form procedural function. I found this handbook page a helpful resource in deciphering the changes that DMU made in replacing hook_menu: An introductory example to Drupal 8 routes and controllers.

src/Controller/DefaultController.php (snippet)


/**
 * @file
 * Contains \Drupal\form_fun\Controller\DefaultController.
 */

namespace Drupal\form_fun\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;

/**
 * Default controller for the form_fun module.
 */
class DefaultController extends ControllerBase {


  public function form_fun_page() {
  /* Returns a render array that produces an HTML list of links. */

}

  public function form_fun_cake_page() {
    $form = \Drupal::formBuilder()->getForm('Drupal\form_fun\Form\FormFunCake');
    return $form;
  }
...

So, wow! The changes to hook_menu are really significant. I found it really helpful to learn about those changes in a super-practical way by going through this process of having Drupal Module Upgrader port my D7 module to D8.

@FIXME: Where DMU Only Gets You So Far

Now, DMU did a pretty good job of creating these files and refactoring functions into classes and such, but where it failed, it added comments with @FIXME. For example, I had several @FIXMEs related to building a $links array and displaying those links as a list using a theme function. Here's the comment left by DMU:


// @FIXME
// l() expects a Url object, created from a route name or external URI.
// $links[] = l(t('Death, or cake? (The basics)'), 'form_fun/cake');

Ok. So what to do? What I found the most helpful in this case was to head over to the change records on Drupal.org. There were some direct links to change records provided by the upgrade-info.html file generated by drush dmu-analyze. But where I didn't find a reference there, I simply searched for the function name in question.

So, for this @FIXME, on the change records page I filtered by keywords l() and found this change record: l() and url() are removed in favor of a routing based URL generation API. Usefully, on this change record, there was an example for how to create a Url object. So...

$links[] = l(t('Death, or cake? (The basics)'), 'form_fun/cake');

...is refactored to...


  // Death or Cake? (The basics)
  $url = Url::fromRoute('form_fun.cake_page');
  $text = 'Death, or cake? (The basics)';
  $links[] = \Drupal::l(t($text), $url);

Editor's note: Turns out, this should be refactored further due to the wonders of Dependency Injection and Traits! To find out how this code was refactored, read this article: Dependency Injection with Traits in Drupal 8

The other thing that was refactored by DMU was the theme function that produced an HTML list of links contained in the $links array, but I found this caused many errors and obviously needed to be changed. I finally found an example of an item_list theme function and refactored DMU's output: return _theme('item_list', array('items' => $links)); to this:


  // Preparing a render array of a HTML item list of the $links array.
  // @FIXME
  // #title value outputs twice on page, as H1 and H3. Fix in twig file.

  $item_list = array(
    '#theme' => 'item_list',
    '#items' => $links,
    '#title' => t('Fun with FormAPI!'),
  );

  // Returning the render array that produces an HTML list of links.
  return $item_list;

You'll notice that I left a @FIXME for myself to fix the duplicated title output, but the takeaway is that Controllers expect render arrays, response objects, or html fragments—not strings. At least that's what the error messages in the "Recent log messages" insisted upon. Also, I discovered that _theme used to be theme() and shouldn't be called directly, so as to encourage the consistent building of renderable arrays. As this is a custom module, I thought I had better refactor. See theme() renamed to _theme() and should not be called directly.

Lessons Learned and What's Next

When I started this process of porting the D7 Form Fun module to D8, I naïvely thought that I would primarily be learning about changes to Drupal's Form API. But what I discovered was equally valuable: lessons on object-oriented PHP, how a major function like hook_menu was replaced with YAML files and Controller php files, new types of files and structure in modules, an invaluable resource in change records, and a great helper in Drupal Module Upgrader.

In my next post on upgrading the Form Fun module to Drupal 8, we'll explore interfaces, a bit more with a Controller, and we'll define a class that defines, builds, validates, and submits a form. Throughout this series on the Drupal 8 Form API, we'll dive into various types of form handling including multi-step and AJAX forms. Stay tuned!

Resources

Related Topics: 

Comments

Why not use drupal/console generator?

As mentioned in the post, because I wanted to try out Drupal Module Upgrader. But, that sounds like another option to try out as well!

Thanks for this very great and insightful post!

Thank you! I am learning a lot going through this process. :)

Add new comment