Drupal 8: Forms, Object-oriented Programming style

  • 17 minute read

Back in August 2013, I wrote the original version of this article on my Drupal Gardens blog. Drupal 8 has continued to be refined since then, so I updated the code to work against the current state of Drupal 8, and am cross posting it here.

Thank you to everyone who provided feedback and encouragement on my first blog post about Object-oriented programming (OOP) in Drupal 8. This is the next installment. In the first post, we examined a very simple controller: one that just outputs "Hello.". In the real world, of course, Drupal modules need to do more than that. Often, they provide forms, store the information that's submitted via those forms, and provide pages that display that stored information. So in this post, let's look at how to do that in Drupal 8. In the process, we'll take a brief look at some key OOP concepts: interfaces and inheritance. The example I'll use for this is a module named firewall. Its job is to restrict access to the website to only visitors coming from an allowed IP address. So it needs to:

  • Provide a page that lists the currently allowed IP addresses.
  • Provide a form that lets you add an IP address to the list of allowed ones.
  • Provide a form that lets you delete an IP address from the list of allowed ones.

A real module like this would also need to implement the logic that denies access to the website for every visitor not coming from an allowed IP address, but I'm leaving that part out of this example, and will cover it in a future blog post about event subscribers. This example is based on functionality that's in Drupal core that lets you maintain a list of IP addresses to ban. In Drupal 7, you can find the code for that functionality within all the functions named system_ip_blocking*(). In Drupal 8, that functionality has been moved to the ban module (within the core/modules directory along with all the other core modules), so you can look through that to see a more "real" example. While I based this post's firewall module on that, I stripped it down to focus on the essential material relevant to this post. Here's the Drupal 7 version of the firewall module:

firewall.info

name = Firewall
core = 7.x
files[] = lib/FirewallStorage.php

firewall.install

<?php

function firewall_schema() {
  $schema['firewall'] = array(
    'fields' => array(
      'ip' => array(
        'type' => 'varchar',
        'length' => 40,
        'not null' => TRUE,
      ),
    ),
    'primary key' => array('ip'),
  );
  return $schema;
}

firewall.module

<?php

function firewall_menu() {
  return array(
    'admin/config/people/firewall' => array(
      'title' => 'Allowed IP addresses',
      'page callback' => 'firewall_list',
      'access callback' => 'user_access',
      'access arguments' =>>array('administer site configuration'),
      'file' => 'includes/firewall.list_page.inc',
    ),
    'admin/config/people/firewall/add' => array(
      'title' => 'Add allowed IP address',
      'page callback' => 'drupal_get_form',
      'page arguments' => array('firewall_add'),
      'access callback' => 'user_access',
      'access arguments' => array('administer site configuration'),
      'file' => 'includes/firewall.add_form.inc',
    ),
    'admin/config/people/firewall/delete/%' => array(
      'title' => 'Delete allowed IP address',
      'page callback' => 'drupal_get_form',
      'page arguments' => array('firewall_delete', 5),
      'access callback' => 'user_access',
      'access arguments' => array('administer site configuration'),
      'file' => 'includes/firewall.delete_form.inc',
    ),
  );
}

lib/FirewallStorage.php

<?php

class FirewallStorage {

  static function getAll() {
    return db_query('SELECT ip FROM {firewall}')->fetchCol();
  }

  static function exists($ip) {
    $result = db_query('SELECT 1 FROM {firewall} WHERE ip = :ip', array(':ip' => $ip))->fetchField();
    return (bool) $result;
  }

  static function add($ip) {
    db_insert('firewall')->fields(array('ip' => $ip))->execute();
  }

  static function delete($ip) {
    db_delete('firewall')->condition('ip', $ip)->execute();
  }

}

includes/firewall.list_page.inc

<?php

function firewall_list() {
  $items = array();
  foreach (FirewallStorage::getAll() as $ip) {
    $items[] = l($ip, "admin/config/people/firewall/delete/$ip");
  }
  $items[] = l(t('Add'), 'admin/config/people/firewall/add');
  return array(
    '#theme' => 'item_list',
    '#items' => $items,
  );
}

includes/firewall.add_form.inc

<?php

function firewall_add($form, $form_state) {
  $form['ip'] = array(
    '#type' => 'textfield',
    '#title' => t('IP address'),
  );
  $form['actions'] = array('#type' => 'actions');
  $form['actions']['submit'] = array(
    '#type' => 'submit',
    '#value' => t('Add'),
  );
  return $form;
}

function firewall_add_validate($form, &$form_state) {
  $ip = $form_state['values']['ip'];
  if (FirewallStorage::exists($ip)) {
    form_set_error('ip', t('This IP address is already listed.'));
  }
  elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) == FALSE) {
    form_set_error('ip', t('Enter a valid IP address.'));
  }
}

function firewall_add_submit($form, &$form_state) {
  $ip = $form_state['values']['ip'];
  FirewallStorage::add($ip);
  watchdog('firewall', 'Added IP address %ip.', array('%ip' => $ip));
  drupal_set_message(t('Added IP address %ip.', array('%ip' => $ip)));
  $form_state['redirect'] = 'admin/config/people/firewall';
}

includes/firewall.delete_form.inc

<?php

function firewall_delete($form, &$form_state, $ip) {
  $form['ip'] = array(
    '#type' => 'value',
    '#value' => $ip,
  );
  $question = t('Are you sure you want to delete %ip?', array('%ip' => $ip));
  $description = t('This action cannot be undone.');
  $confirm_text = t('Delete');
  $cancel_text = t('Cancel');
  $cancel_path = 'admin/config/people/firewall';
  return confirm_form($form, $question, $cancel_path, $description, $confirm_text, $cancel_text);
}

function firewall_delete_submit($form, &$form_state) {
  $ip = $form_state['values']['ip'];
  FirewallStorage::delete($ip);
  watchdog('firewall', 'Deleted IP address %ip.', array('%ip' => $ip));
  drupal_set_message(t('Deleted IP address %ip.', array('%ip' => $ip)));
  $form_state['redirect'] = 'admin/config/people/firewall';
}

There are a couple things that are different in the above than how many Drupal 7 modules are written. The first is that the list page and the two forms are each separated into their own file. Typically, all 3 would be put into a file like firewall.admin.inc. However, I think the more fine grained separation makes it easier to read in the context of this article. It also matches the granularity at which files are separated in Drupal 8, so makes it easier to compare the before and after.

The more important difference is that there's a FirewallStorage class that's an intermediary between the pages/forms and the database queries. In MVC terminology, this is called the model. What I've noticed to be more common in Drupal 7 modules is one of:

  • No model at all, but rather the controllers (page callbacks and forms) making direct database queries. In fact, the Drupal 7 system_ip_blocking*() functions this example is based on do that. However, that's poor practice, because suppose someone else wants to create a firewall_better_ui module. Now that module's pages and forms would need to write direct database queries to the firewall table. And suppose another module wants to provide firewall management integration with the Services module. Since that module would use service callbacks instead of forms, it too would need to write direct database queries to the firewall table. So, in the future, if you want to change how or where the IP addresses are stored, you would break both those modules plus any others out there integrating with your module.
  • Model functions within the .module file. Drupal 7 core's taxonomy module does this partially, with functions like taxonomy_get*() and taxonomy_term_save() allowing modules to for the most part work with taxonomy data without querying the taxonomy tables directly. This is much better than not having the model intermediary at all. However, having the model as functions in the .module file means those functions are loaded into memory on every page request, even for pages that make no use of it. An advantage of splitting them up into classes is that they can be only loaded when needed. I also prefer the organization that classes provide, because in a big module, there might be dozens of such functions, and classes are a good way to organize them into related chunks. The most important advantage of classes though, is that a different module can swap out the class that's used. For example, suppose I have a website where I want to use the firewall module, without changing its UI or functionality, but I want to store the IP addresses in Redis instead of in the database. With the code above, I could do that by writing a custom module that alters the file from which the FirewallStorage class is loaded, replacing it with an alternate implementation of that class that issues Redis queries instead of SQL queries. Altering which file a class is loaded from is not the best way to achieve this flexibility, and in a future blog post, I'll write about the better way to do that in Drupal 8: dependency injection. But simply by writing model code as classes, you make it possible (not as elegantly as you can with dependency injection, but still possible) for people to swap in alternate implementations, even in Drupal 7.

Now, here's the Drupal 8 code for the firewall module:

firewall.info.yml

name: Firewall
type: module
core: 8.x

firewall.install

<?php

function firewall_schema() {
  $schema['firewall'] = array(
    'fields' => array(
      'ip' => array(
        'type' => 'varchar',
        'length' => 40,
        'not null' => TRUE,
      ),
    ),
    'primary key' => array('ip'),
  );
  return $schema;
}

firewall.routing.yml

firewall.list:
  path: 'admin/config/people/firewall'
  defaults:
    _content: '\Drupal\firewall\ListController::content'
    _title: 'Allowed IP addresses'
  requirements:
    _permission: 'administer site configuration'

firewall.add:
  path: 'admin/config/people/firewall/add'
  defaults:
    _form: '\Drupal\firewall\AddForm'
    _title: 'Add allowed IP address'
  requirements:
    _permission: 'administer site configuration'

firewall.delete:
  path: 'admin/config/people/firewall/delete/{ip}'
  defaults:
    _form: '\Drupal\firewall\DeleteForm'
  requirements:
    _permission: 'administer site configuration'

firewall.module

<?php
function firewall_menu_link_defaults() {
  return array(
    'firewall.list' => array(
      'link_title' => 'Allowed IP addresses',
      'route_name' => 'firewall.list',
      'parent' => 'user.admin_index',
    ),
  );
}

lib/Drupal/firewall/FirewallStorage.php

<?php

namespace Drupal\firewall;

class FirewallStorage {

  static function getAll() {
    return db_query('SELECT ip FROM {firewall}')->fetchCol();
  }

  static function exists($ip) {
    $result = db_query('SELECT 1 FROM {firewall} WHERE ip = :ip', array(':ip' => $ip))->fetchField();
    return (bool) $result;
  }

  static function add($ip) {
    db_insert('firewall')->fields(array('ip' => $ip))->execute();
  }

  static function delete($ip) {
    db_delete('firewall')->condition('ip', $ip)->execute();
  }

}

lib/Drupal/firewall/ListController.php

<?php

namespace Drupal\firewall;

class ListController {

  function content() {
    $items = array();
    foreach (FirewallStorage::getAll() as $ip) {
      $items[] = array(
        '#type' => 'link',
        '#title' => $ip,
        '#route_name' => 'firewall.delete',
        '#route_parameters' => array('ip' => $ip),
      );
    }
    $items[] = array(
      '#type' => 'link',
      '#title' => t('Add'),
      '#route_name' => 'firewall.add',
    );
    return array(
      '#theme' => 'item_list',
      '#items' => $items,
    );
  }

}

lib/Drupal/firewall/AddForm.php

<?php

namespace Drupal\firewall;

use Drupal\Core\Form\FormInterface;

class AddForm implements FormInterface {

  function getFormID() {
    return 'firewall_add';
  }

  function buildForm(array $form, array &$form_state) {
    $form['ip'] = array(
      '#type' => 'textfield',
      '#title' => t('IP address'),
    );
    $form['actions'] = array('#type' => 'actions');
    $form['actions']['submit'] = array(
      '#type' => 'submit',
      '#value' => t('Add'),
    );
    return $form;
  }

  function validateForm(array &$form, array &$form_state) {
    $ip = $form_state['values']['ip'];
    if (FirewallStorage::exists($ip)) {
      form_set_error('ip', $form_state, t('This IP address is already listed.'));
    }
    elseif (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE) == FALSE) {
      form_set_error('ip', $form_state, t('Enter a valid IP address.'));
    }
  }

  function submitForm(array &$form, array &$form_state) {
    $ip = $form_state['values']['ip'];
    FirewallStorage::add($ip);
    watchdog('firewall', 'Added IP address %ip.', array('%ip' => $ip));
    drupal_set_message(t('Added IP address %ip.', array('%ip' => $ip)));
    $form_state['redirect_route']['route_name'] = 'firewall.list';
  }

}

lib/Drupal/firewall/DeleteForm.php

<?php

namespace Drupal\firewall;

use Drupal\Core\Form\ConfirmFormBase;

class DeleteForm extends ConfirmFormBase {

  protected $ip;

  function getFormID() {
    return 'firewall_delete';
  }

  function getQuestion() {
    return t('Are you sure you want to delete %ip?', array('%ip' => $this->ip));
  }

  function getConfirmText() {
    return t('Delete');
  }

  function getCancelRoute() {
    return array('route_name' => 'firewall.list');
  }

  function buildForm(array $form, array &$form_state, $ip = '') {
    $this->ip = $ip;
    return parent::buildForm($form, $form_state);
  }

  function submitForm(array &$form, array &$form_state) {
    FirewallStorage::delete($this->ip);
    watchdog('firewall', 'Deleted IP address %ip.', array('%ip' => $this->ip));
    drupal_set_message(t('Deleted IP address %ip.', array('%ip' => $this->ip)));
    $form_state['redirect_route']['route_name'] = 'firewall.list';
  }

}

A bunch of things here should look familiar from the previous article:

  • The firewall.info file changed to firewall.info.yml.
  • Parts of firewall_menu() got moved into firewall.routing.yml.
  • Other parts of firewall_menu() got moved into firewall_menu_link_defaults(), but note that that will move to a YAML file prior to Drupal 8 release.
  • The FirewallStorage class moved into a namespace, but other than that hasn't changed.
  • The firewall_list() page callback got changed to the Drupal\firewall\ListController class, and the links generated by it reference route names instead of paths. In the previous article's hello module example, I had put HelloController into a Controller sub-namespace, whereas, here I didn't for ListController. Again, how you want to organize your namespaces is up to you.

What's new in this example are the two form classes. Let's look at AddForm first. In the Drupal 7 version of this module, within firewall.add_form.inc, we had the primary form function: firewall_add(), and then two other functions: firewall_add_validate() and firewall_add_submit(), for the form to actually be useful. In OOP terminology, when you have a set of functions that need to exist together in order to accomplish something, that's an interface. In Drupal 7, documentation of the Form API is how we communicate to developers that whenever you define a form generation function, then there are also two additional specially named functions for your validation and submission logic. With OOP, we can use PHP's language feature of defining interfaces explicitly, and so Drupal 8 core provides a Drupal\Core\Form\FormInterface interface to do that. The primary form generation function is buildForm(), and the validation and submission functions are validateForm() and submitForm(). There's also a new function, getFormID(), which needs to return the form id, which is used in various places, like resolving which hook_form_FORM_ID_alter() functions to call. In Drupal 7, the form id by default is the same as the name of the form generation function, but with the move to that being a buildForm() function in a namespaced class, we needed a separate function for getting a short id. With the interface defined, a particular form just needs to implement the interface, and you can see in the AddForm code above how that's done. You'll notice that the contents of the build, validate, and submit functions are the same as in the Drupal 7 version of the form other than passing $form_state to form_set_error(), and upon completion, redirecting to a route name rather than a path.

Now let's look at DeleteForm. This isn't just a regular form, it's a confirmation form. There are certain things that are common to all confirmation forms. They typically don't need validation (they're usually just a submit button and a cancel link), and they share a common pattern of a question, a description, a label for the confirmation button, a path to go to for the cancel link, etc. With OOP, all of these commonalities can be built into a base class, Drupal\Core\Form\ConfirmFormBase, and via inheritance, each specific confirmation form can just override the parts that it needs to. With that in mind, I hope that the code above for DeleteForm makes sense. To sum up:

  • It's good practice, even in Drupal 7, to separate the code that queries or changes the state/data on the website from the pages and forms that provide specific views of that data and UIs that control that data.
  • To build a form, you need to create a class that implements Drupal\Core\Form\FormInterface.
  • The easiest way to build a confirmation form is to create a class that extends Drupal\Core\Form\ConfirmFormBase.
  • In future articles, I'll write about how to make the firewall module implement an event subscriber to limit site access to the allowed IP addresses, and how to use dependency injection to make the FirewallStorage class more easily swappable. Or you can cheat and just look at how the Drupal 8 ban module does it.