Continuing from Evan’s blog post on building pages with Paragraphs and writing custom blocks of content as fields, I will walk you through how to create a custom field-formatter in Drupal 8 by example.

A field-formatter is the last piece of code to go with the field-type and the field-widget that Evan wrote about in the previous blog post. While the field-type tells Drupal about what data comprises a field, the field-formatter is responsible for telling Drupal how to display the data stored in the field.

To recap, we defined a hashtag_search field type in the previous blog post whose instances will be composed of two items: the hashtag to search for, and the number of items to display. We want to convert this data into a list of the most recent n tweets with the specified hashtag.

A field-formatter is a Drupal plugin, just like its respective field-type and field-widget. They live in <module_path>/src/Plugin/Field/FieldFormatter/ and are namespaced appropriately: Drupal\<module_name>\Plugin\Field\FieldFormatter.

<?php

namespace Drupal\my_module\Plugin\Field\FieldFormatter;


use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Core\Field\FormatterBase;
use Drupal\Core\Form\FormStateInterface;

/**
 * @FieldFormatter(
 *     id = "hashtag_formatter",
 *     label = @Translation("Hashtag Search"),
 *     field_types = {
 *      "hashtag_search"
 *     }
 * )
 */
class HashtagFormatter extends FormatterBase
{

    public function viewElements(FieldItemListInterface $items, $langcode)
    {
        return array();
    }
}

We tell Drupal important details about our new field-formatter using a @FieldFormatter class annotation. We declare its unique id; a human-readable, translatable label; and a list of field_types that it supports.

The most important method in a field-formatter is the viewElements method. It’s responsibility is returning a render array based on field data being passed as <Drupal\Core\Field\FieldItemListInterface> $items.

Let’s look at the code:

<?php

use Drupal\my_module\Twitter\TwitterClient;
use Drupal\my_module\Twitter\TweetFormatter;

...

    /**
     * @var TwitterClient
     */
    protected $twitter;

    /**
     * @var TweetFormatter
     */
    protected $formatter;

    ...

    public function viewElements(FieldItemListInterface $items, $langcode)
    {
        $element = [];

        // Iterate through all  values from one ore more fields...
        foreach ($items as $delta => $item) {

            try {

                // Use service to fetch `$item->count` no. of tweets matching the hashtag...
                $results = $this->twitter->search($item->hashtag_search, $item->count);

                // Map through each tweet and generate an HTML-rich version
                // complete with hashtags, mentions, URLs, etc. as links, using a formatter service.
                // Assign the HTML-rich version to a "formatted_text" property.
                $statuses = array_map(function ($s) {
                    $s['formatted_text'] = $this->formatter->toHtml($s['text'], $s['entities']);
                    return $s;
                }, $results['statuses']);

                // Add a header...
                if (!empty($statuses)) {
                    $element[$delta]['header'] = [
                        '#markup' => '<h4>#' . $item->hashtag_search . '</h4>'
                    ];
                }

                // Tell Drupal that each status is to be rendered
                // by the `my_module_status` theme.
                foreach ($statuses as $status) {
                    $element[$delta]['status'][] = [
                        '#theme' => 'my_module_status',
                        '#status' => $status
                    ];
                }

            } catch (\Exception $e) {
                // If an error/exception occur i.e. Twitter auth errors, log it, and carry-on
                // gracefully...
                $this->logger->error('[:exception]: %message', [
                    ':exception' => get_class($e),
                    '%message' => $e->getMessage(),
                ]);
                continue;
            }
        }

        // Include the `twitter_intents` library defined by this module which holds
        // `Drupal.behavior`s that dictate functionality of the Twitter block UI.
        $element['#attached']['library'][] = 'my_module/twitter_intents';

        return $element;
    }

    ...

See https://github.com/bezhermoso/tweet-to-html-php for how TweetFormatter works. Also, you can find the source-code for the basic Twitter HTTP client here: https://gist.github.com/bezhermoso/5a04e03cedbc77f6662c03d774f784c5

Custom theme renderer

As shown above, each individual tweets are using the my_module_status render theme. We’ll define it in the my_module.module file:

<?php

/**
 * Implements hook_theme().
 */
function my_module_theme($existing, $type, $theme, $path) {
  $theme = [];
  $theme['my_module_status'] = array(
    'variables' => array(
      'status' => NULL
    ),
    'template' => 'twitter-status',
    'render element' => 'element',
    'path' => $path . '/templates'
  );

  return $theme;
}

With this, we are telling Drupal to use the template file modules/my_module/templates/twitter-status.twig.html for any render array using my_module_status as its theme.

Render caching

Drupal 8 does a good job caching content: typically any field formatter is only called once and the resulting collective render arrays are cached for subsequent page loads until the Drupal cache is cleared. We don’t really want our Twitter block to be cached for that long. Since it is always great practice to keep caching enabled, we can define how caching is to be applied to our Twitter blocks. This is done by adding cache definitions in the render array before we return it:

<?php

      public function viewElements(...)
      {

        ...

        $element['#attached']['library'][] = 'my_module/twitter_intents';
        /* Cache block for 5 minutes. */
        $element['#cache']['max-age'] = 60 * 5;
        
        return $element;
      }

Here we are telling Drupal to keep the render array in cache for 5 minutes. Drupal will still cache the rest of the page’s elements how they want to be cached, but will call our field formatter again – which pulls fresh data from Twitter – if 5 minutes has passed since the last time it was called.