When you are used to working in Drupal 8 and beyond, having to make changes to custom Drupal 7 code can be unpleasant. If you’re lucky, the code was well written and maintained and you can make your changes without feeling like you’re adding to a house of cards. Too often, though, the code is in a state where it could really use refactoring.

One of the many nice changes Drupal 8 brought about was changing most of the code over to object-oriented programming, or OOP. There are many benefits to this and one of them is that it organizes most of the code into classes. In Drupal 7, all that code was dumped into the .module files because that’s how things were done back then. But it doesn’t need to be that way.

Why Add OOP?

Drupal 7 may not have the OOP framework that Drupal 8 built, and you still need the hooks, but there are benefits to adding OOP to your refactor:

  • Upgrades - It gets your custom code closer to Drupal 8/9. If there is any thought of an upgrade in the site’s future, having the custom modules as close to Drupal 8/9 as possible will make that upgrade easier.
  • Clean Code - It makes your Drupal 7 code cleaner and easier to maintain. Even if an upgrade is a long way off, having clean and organized code is much easier to maintain and reduces the chance of knocking down that house of cards when you need to make a change.
  • Easier Refactoring - It makes refactoring easier because you can work on a copy of the code that is moved to a new place rather than trying to do everything in place. As you move and refactor each function, you can easily see what code is refactored and what is still the old code.
  • Testing - It makes it easier to test your refactored code. There’s more on that in the “Testing the refactoring” section below. If you are following along with this blog post and making changes to your module as you go, you’ll want to read that section before you start.

An Example Module

For the purpose of examples, let’s say we are refactoring a custom module called XYZ Events. Our site is for company XYZ and this module is where we stored all the custom functionality related to our events.

This module has custom menu items, blocks, and a bunch of miscellaneous helper functions all sitting in the .module file and we want to clean that up.

I’ll reference this fictional module to provide examples along the way.

Make class loading simple

To make using classes much easier, start with a very handy contributed module: X Autoload. X Autoload lets you make use of the automagic class loading that Drupal 8 has just by putting your classes in the proper directories and setting the namespace. With that in place, you are ready to start moving your code into classes.

Whenever you add a new class, be sure to clear the cache before you try to use it.

Services for all the miscellaneous functions

While hooks and a few other things need to stay in the .module file, chances are there are a lot of miscellaneous functions that can be organized into one or more service classes. These won’t be true D8/9 services with the service.yml and all of that but they serve the same purpose. In theory, you could write your own service container if you wanted to push this even further but even just regular classes help.

Make the events service:

  • Add the directory “src” to the root of the xyz_events directory.
  • In there, add EventsService.php. It’s not required to have “Service” in the name but it helps make the purpose clear.
  • The basic outline of the class looks like this:
<?php

namespace Drupal\xyz_events;

/**
 * Utility functions related to events.
 */
class EventsService {

}

Move the code:

For each of the non-hook, non-callback functions in the .module file (and .inc files), copy it into the appropriate class. Some functions might make more sense in a site-wide utility class if they aren’t directly related to the module’s “theme”. Once it’s in its new home, do the cleanup and refactoring:

  • Change the function names to camel case and remove the module name (ie: xyz_events_get_event_list() becomes getEventList())
  • Add the public, protected, or private designation to the front. Since these used to be in a .module file, most of them are likely to be public. But, if any functions are only used by other functions that are now in the same class, those could be changed to protected or private.
  • Now is a good time to clean up any coding standards issues. I like to use Drupal 8/9’s standards if I know I’m on a PHP version that supports it such as using short array syntax. This gets it as close to D8/9 as possible.
  • Do whatever refactoring is needed to make the code easier to follow, improve performance, fix bugs, etc.

Update calls:

Using grep, check your whole site to find out all the places where that function was being called. For any place it’s being called that isn’t already in the class, change the call from the old function to the new method:

  • Add $events_service = new EventsService(); if that isn’t already in scope.
  • Change xyz_events_get_event_list() to $events_service->getEventList()

If the call is within the class already, change it to use “$this” instead of the service reference variable:

  • xyz_events_get_event_list() to $this->getEventList()

You now have all the miscellaneous custom code in a service (or multiple services if it makes sense to divvy them up). When moving to Drupal 8/9, all that’s needed is to update the class so that it’s a proper service container service and then change the calls to go through the container rather than using “new SomeClass()”.

Block classes

Drupal 8 introduced blocks as classes which is much cleaner than the old style that used multiple hooks. If you have multiple custom blocks each with a chunk of code, hook_block_view() can get quite long and hard to follow. While the hooks themselves are still needed, the actual code can be split off into classes. hook_block_info() stays the same but hook_block_view() becomes much simpler. 

  • If you haven’t already, add a directory “src” at the root of the module directory.
  • In “src” add a directory structure “Plugin/Block”.
  • For each block in hook_block_view():
    • Add a file in “Block” that is BlockNameBlock.php. Like services, the “Block” at the end isn’t required but makes it clearer what the class does. For our example module, we end up with UpcomingEventsBlock.php and FeaturedEventsBlock.php.
    • Take all the code for generating that block out of the hook and put it in the class.
    • Replace the content in the hook with a call to the class.
  • If your blocks have a lot of similar functionality, you can take advantage of inheritance and move the common functionality into a base block. In our case, since both blocks are listing events, we add EventListingBlockBase.php.

In the .module file we have:

/**
 * Implements hook_block_info().
 */
function xyz_events_block_info() {
  $blocks = [];

  $blocks['upcoming'] = [
    'info' => t('Show upcoming events'),
  ];

  $blocks['featured'] = [
    'info' => t('Show featured events'),
  ];

  return $blocks;
}

/**
 * Implements hook_block_view().
 */
function xyz_events_block_view($delta = '') {
  $block = [];

  switch ($delta) {
    case 'upcoming':
      $block_object = new UpcomingEventsBlock();
      $block = $block_object->build();
      break;

    case 'featured':
      $block_object = new FeaturedEventsBlock();
      $block = $block_object->build();
      break;
  }

  return $block;
}

And then our blocks:

EventListingBlockBase.php

<?php

namespace Drupal\xyz_events\Plugin\Block;

use Drupal\xyz_events\EventsService;

/**
 * Provides a base class for the event listing blocks.
 */
abstract class EventListingBlockBase {

  /**
   * The events service.
   *
   * @var \Drupal\xyz_events\EventsService
   */
  protected $EventsService;

  /**
   * EventListingBlockBase constructor.
   */
  public function __construct() {
    $this->EventsService = new EventsService();
  }

  /**
   * Builds the content for the block.
   */
  abstract public function build();
  
  /**
   * Format the content into the array needed for the block.
   *
   * @param string $title
   *   The block title.
   * @param array $items
   *   The complete list of items.
   * @param string $empty
   *   The text to print if there are no items.
   * @param string $theme_hook
   *   The theme hook for the block content.
   *
   * @return array
   *   The block content array.
   */
  protected function formatContent(string $title, array $items, string $empty, string $theme_hook) {
    // Only keep the empty text if there are no items.
    $empty = (count($items) == 0) ? $empty : '';

    $variables = [
      'items' => $items,
      'empty' => $empty,
    ];

    $content = [
      'subject' => $title,
      'content' => theme($theme_hook, $variables),
    ];

    return $content;
  }

}

UpcomingEventsBlock.php and FeaturedEventsBlock.php both use the following code, just altering the “upcoming” to “featured” as appropriate.

<?php

namespace Drupal\xyz_events\Plugin\Block;

/**
 * Provides the content for the UpcomingEventsBlock block.
 */
class UpcomingEventsBlock extends EventListingBlockBase {

  /**
   * {@inheritdoc}
   */
  public function build() {
    // Block title.
    $title = t('Upcoming Events');

    // Get the list of events.
    $items = $this->EventsService->getEventList('upcoming')

    // What it should print if there aren't any.
    $empty = t('There are no upcoming events.');

    // The theme hook to use to format the contents of the block.
    $theme_hook = 'xyz_events_upcoming_events';

    return $this->formatContent($title, $items, $empty, $theme_hook);
  }

}

Now all the content for building each block is encapsulated in the class for that block. When moving to Drupal 8/9, add the block annotation that it uses to identify blocks and remove the block-related hooks from the .module file.

If your blocks need configuration, this can be taken a step further by adding the form code and save code as methods on the block class and then referencing those from hook_block_configure() and hook_block_save().

Menu items to controllers

While hook_menu itself usually doesn’t get too overwhelming due to the actual code for the menu items being in separate functions, it does contribute to the .module file bloat. It’s also a lot nicer to have the menu items be in individual controllers like in Drupal 8/9.

To make this happen:

  • If you haven’t already, add a “src” directory at the root of your module.
  • Add a “Controller” directory under that.
  • For each menu item, add a file in there that is SomeController.php. Like services, “Controller” isn’t required but it makes it clearer. Another option is to use “Page” if the item corresponds to a viewable page rather than an API callback. For our example module, we end up with “UpcomingEventsController.php” and “FeaturedEventsController.php”.
  • As with blocks, a base controller can be used if the controllers have similar code.
  • Replace the hook code with a reference to the class (explained below).

There are two ways that the hook_menu() code can reference your class. Using a static function on the class and calling it directly or using a wrapper function to call the object.

Static method:

  • In hook_menu:
    'page callback' => 'UpcomingEventsController::build',
  • The build method on the class needs to be static.

Wrapper method:

  • In hook_menu:
    'page callback' => 'xyz_events_controller_callback',
    'page arguments' => ['controller_class' => 'UpcomingEventsCoursesPage'],
  • function xyz_events_controller_callback() needs to be in the .module file. (see below)
  • The build method on the class does not need to be static as we are instantiating an object.

In the .module file:

/**
 * Implements hook_menu().
 */
function xyz_events_menu() {
  $items = [];

  $items['events/upcoming'] = [
    'title' => 'Upcoming events',
    'page callback' => 'xyz_events_controller_callback',
    'page arguments' => ['controller_class' => 'UpcomingEventsController'],
    'type' => MENU_NORMAL_ITEM,
  ];

  $items['events/featured'] = [
    'title' => 'Featured events',
    'page callback' => 'xyz_events_controller_callback',
    'page arguments' => ['controller_class' => 'FeaturedEventsController'],
    'type' => MENU_NORMAL_ITEM,
  ];

  return $items;
}

/**
 * Menu callback that wraps the controllers.
 */
function xyz_events_controller_callback($controller_class) {
  $controller_class = "\\Drupal\\xyz_events\\Controller\\$controller_class";
  $controller = new $controller_class();
  return $controller->build();
}

The classes:

EventListingControllerBase.php

<?php

namespace Drupal\xyz_events\Controller;

use Drupal\xyz_events\EventsService;

/**
 * Provides a base class for the event listing pages.
 */
abstract class EventListingControllerBase {

  /**
   * The events service.
   *
   * @var \Drupal\xyz_events\EventsService
   */
  protected $eventsService;

  /**
   * UserInProgressProgramsBlock constructor.
   */
  public function __construct() {
    $this->eventsService = new EventsService();
  }

  /**
   * Builds the content for the page.
   */
  abstract public function build();
  
}

UpcomingEventsController.php and FeaturedEventsController.php have the same code with “upcoming” changed to “featured” as needed.

<?php

namespace Drupal\xyz_events\Controller;

/**
 * Provides the content for the UpcomingEventsController controller.
 */
class UpcomingEventsController extends EventListingControllerBase {

  /**
   * {@inheritdoc}
   */
  public function build(): array {
    // Get the list of events.
    $items = $this->eventsService->getEventList('upcoming');

    $content = theme('xyz_events_upcoming_events', ['items' => $items]);
    return ['#markup' => $content];
  }

}

The rest of the hooks

While Drupal 7 relies on a lot of hooks and these need to be in a .module or .inc file so they can be found, there’s nothing requiring the actual code for the hooks to live in the functions. Like we did with blocks and menu items, the hook functions can serve as a wrapper around a call to a class where the actual code lives.

Testing the refactoring

While writing actual tests is the best way to test, that isn’t always possible with time and budget considerations. Still, you want to be sure your refactored code gets you the same results as the old code. Moving functions into classes while refactoring helps with that.

  • Add an include file to the root of the module. Ex: xyz_events_replaced_functions.inc
  • At the top of the .module file, add include_once 'xyz_events_replaced_functions.inc';
  • As you move functions into the classes, copy them into this file instead of deleting them from the .module file.

This keeps all the old functions active at the same time as the new ones which lets you test them in parallel within the same site.

Add this to the top of the module file:

/**
 * Implements hook_menu().
 */
function xyz_events_menu() {
  // Adds a testing page for dev use.
  $items['admin/code-test'] = array(
    'access arguments'  => array('administer content'),
    'description'       => 'Place to test code.',
    'page callback'     => xyz_events_test_code',
    'title'             => 'Code testing',
  );

  return $items;
}

/**
 * Place to put test code that is called from admin/code-test.
 */
function xyz_events_test_code() {
  print("Test completed.");
}


Within xyz_events_test_code() you can do something like this:

Within xyz_events_test_code() you can do something like this:

$events_service = new EventsService();
$old_list = xyz_events_get_event_list();
$new_list = $events_service->getEventList();

Set a breakpoint at the top of the function and then visit /admin/code-test on the site. You can then step through and compare what you get running the original function vs running your refactored function and make sure the result is either the same or has any differences that you intended with the refactor.

Once you have finished and are ready to commit your refactor, delete the include file, the include line, and the testing code.

Wrapping up

At this point, your .module file should be much smaller, consisting of just hooks and things like callback functions that can’t be moved into a class. Your directory structure should look a lot like a Drupal 8/9 module with all the code wrapped up into classes. There will still be work needed to move to Drupal 8/9 in dealing with the API changes but the actual structure of the code and where things are found shouldn’t need much changing. And maintaining the Drupal 7 code until that time should be a much more pleasant experience.

Further reference

This blog post came from actual client work but that work was inspired by others. These two were my biggest sources for reference:

I didn't get into forms above but here's an article that covers them: https://roomify.us/blog/object-oriented-forms-in-drupal-7/