A twig template is rendering a list of links that come with classes. The basic:

{{ mylink }}

twig code will output something like

<a href="#" class="someclass" >the text</a>

Not all links have classes. I want to write a twig template that will instead output something like this:

<a href="#" class="someclass" >
  <span class="sprite someclass" ></span>
  the text</a>

What I've tried:

  1. I looked for the twig template to override. Unfortunately it seems that links are not rendered by a twig template.

  2. I tried updating the twig variable like

    set mylink['#title'] = "<span>...</span>" ~ mylink['#title']

    But it won't let me do that.

  • It must be only in twig template? I'm able to change markup and set classes from UI (content type > manage display form). – Vagner May 1 '16 at 0:21

Here's a twig only solution for a particular field that needs this treatment; it's not a generic solution for all links everywhere.


<ul class="field--name-field-links">
  {% for item in content.field_links %}
  {% if item['#title'] %}
      <a href="{{ item['#url'] }}" class="{{ item['#options'].attributes.class|join(' ') }}" >
        {% if item['#options']['attributes']['class'] %}
          <span class="sprite {{ item['#options']['attributes']['class']|join(" ") }}"></span>
        {% endif %}
        {{ item['#title'] }}
  {% endif %}
  {% endfor %}
  • 1
    OMG finally, I've been looking for 2 days for a solution for this problem. I still can't understand how twig is outputing html when we pass it item.link which is an array. Anyone has a doc for that? – Guillaume Bois Apr 26 '16 at 14:21
  • Oh gush... Unfortunatly this solution works only partially. I want to alter the language switcher links and using item.link['#url'] is giving the same url for all languages! – Guillaume Bois Apr 26 '16 at 14:29
  • @GuillaumeBois Can you test drupal.stackexchange.com/a/199998/54619 to see if solve the problem of 'language switcher'? Thanks – Vagner May 5 '16 at 12:43

I'm not found a way to change link '#markup' in twig, but there is way to change it on render phase.
I made this little module that extend Link functionality and make it able to inject some stuff on rendered link. So lets do some code, I will explain in comments...

Module file structure:

 | - src
   | - Element
     | BetterLink.php
   | - Plugin
     | - FieldFormatter
       | BetterLinkFormatter.php
 | better_link.info.yml
 | better_link.module

File contents:


name: 'Better link'
type: module
package: 'Field types'
description: 'A very nice better link'
core: '8.x'
  - field
  - link



use Drupal\Core\Routing\RouteMatchInterface;

 * Implements hook_help().
 * Just some words about the module.
function better_link_help($route_name, RouteMatchInterface $route_match) {
  switch ($route_name) {
    case 'help.page.better_link':
      $output = '';
      $output .= '<h3>' . t('About') . '</h3>';
      $output .= '<p>' . t('Provide a improved link formatter and renderer for a custom link markup.') . '</p>';
      $output .= '<p>' . t('Will be added a span html tag right before link content.') . '</p>';
      $output .= '<p>' . t(' - Link class can be added throught manage display.') . '</p>';
      $output .= '<p>' . t(' - Span class can be added throught manage display.') . '</p>';
      return $output;



 * @file
 * Contains \Drupal\better_link\Plugin\Field\FieldFormatter\BetterLinkFormatter.

namespace Drupal\better_link\Plugin\Field\FieldFormatter;

use Drupal\Core\Field\FieldItemListInterface;
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\Unicode;
use Drupal\Core\Form\FormStateInterface;
use Drupal\link\Plugin\Field\FieldFormatter\LinkFormatter;

* Plugin implementation of the 'better_link' formatter.
* @FieldFormatter(
*   id = "better_link",
*   label = @Translation("Better Link"),
*   field_types = {
*     "link"
*   }
* )
class BetterLinkFormatter extends LinkFormatter {
   * {@inheritdoc}
  public static function defaultSettings() {
    $settings = parent::defaultSettings();
    //Keeping simple...
    $settings['span_class'] = '';
    $settings['link_class'] = '';
    //... but feel free to add, tag_name, buble_class, wraper_or_inside
    return $settings;

   * {@inheritdoc}
  public function settingsForm(array $form, FormStateInterface $form_state) {
    $form = parent::settingsForm($form, $form_state);
    //Make sure that you always store a name that can be used as class
    $settings['link_class'] = Html::cleanCssIdentifier(Unicode::strtolower($this->getSetting('link_class')));
    $settings['span_class'] = Html::cleanCssIdentifier(Unicode::strtolower($this->getSetting('span_class')));

    $form['link_class'] = array(
      '#title' => $this->t('Inject this class to link'),
      '#type' => 'textfield',
      '#default_value' => $settings['link_class'],
    $form['span_class'] = array(
      '#title' => $this->t('Inject this class to span'),
      '#type' => 'textfield',
      '#default_value' => $settings['span_class'],
    return $form;

   * {@inheritdoc}
  public function settingsSummary() {
    $summary = parent::settingsSummary();
    //Same here. Somehow if you use setSettings here don't reflect in settingsForm
    $settings['link_class'] = Html::cleanCssIdentifier(Unicode::strtolower($this->getSetting('link_class')));
    $settings['span_class'] = Html::cleanCssIdentifier(Unicode::strtolower($this->getSetting('span_class')));

    //Summary is located in the right side of your field (in manage display)
    if (!empty($settings['link_class'])) {
      $summary[] = t("Class '@class' will be used in link element.", array('@class' => $settings['link_class']));
    else {
      $summary[] = t('No class is defined for link element.');

    if (!empty($settings['span_class'])) {
      $summary[] = t("Class '@class' will be used in span element.", array('@class' => $settings['span_class']));
    else {
      $summary[] = t('No class is defined for span element.');

    return $summary;

   * {@inheritdoc}
  public function viewElements(FieldItemListInterface $items, $langcode) {
    $elements = parent::viewElements($items, $langcode);
    //Yeah, here too, same 'problem'.
    $settings['link_class'] = Html::cleanCssIdentifier(Unicode::strtolower($this->getSetting('link_class')));
    $settings['span_class'] = Html::cleanCssIdentifier(Unicode::strtolower($this->getSetting('span_class')));

    foreach ($items as $delta => $item) {
      //Lets change the render element type and inject some options that will
      //be used in render phase
      if (isset($elements[$delta]['#type'])) {
        $elements[$delta]['#type'] = 'better_link';
        $elements[$delta]['#options']['#link_class'] = $settings['link_class'];
        $elements[$delta]['#options']['#span_class'] = $settings['span_class'];
    //Next step, render phase, see ya...
    return $elements;



 * @file
 * Contains \Drupal\better_link\Element\BetterLink.

namespace Drupal\better_link\Element;

use Drupal\Core\Render\Element\Link;

 * Provides a better_link render element. Almost the same as link.
 * @RenderElement("better_link")
class BetterLink extends Link {
   * {@inheritdoc}
  public function getInfo() {
    $class = get_class($this);
    return array(
      '#pre_render' => array(
        array($class, 'preRenderLink'),

   * {@inheritdoc}
  public static function preRenderLink($element) {
    //Hello again. Lets work.
    //Before Drupal create the rendered link element lets inject our stuff...
    //...Our class to link
    $element['#options']['attributes']['class'][] = $element['#options']['#link_class'];
    //...Build span classes
    $span_classes = $element['#options']['#span_class'] . ' ' . $element['#options']['#link_class'];
    //...And get rid them.
    //Lets Drupal do the hard work
    $element = parent::preRenderLink($element);
    //Here is where the magic happens ;)
    if (!empty($element['#markup'])) {
      //Inject our span right before link content.
      $element['#markup'] = str_replace('">', "\"><span class='$span_classes'></span>", $element['#markup']);
      //Side comment - Thank you spaceless, str_replace can be used here
    //Now, whatever you change in your url or another object will not maintain,
    //the only thing that will be returned in the end is
    //$element['#markup'], so this is the only thing you can change.
    return $element;


This will work for all of your link fields, sure, if you change its formatter in manage display (editing your node type).

I hope that can be useful.

Request to @artfulrobot: Can you test this module? I think that translate issue can be solved this way.

  • Yeah, thank you for such a long and detailed answer. I think there's a massive failure in d8's twig layer with huge php-based solutions to what should be a simple theming issue. But thanks for posting, v helpful. – artfulrobot May 3 '16 at 19:35
  • @artfulrobot You're probably in a better position to answer this than me - which of the answers here do you think the bounty should go to? – Clive May 6 '16 at 10:05
  • @clive thanks, but your bounty, your call. The question I asked was about Twig. Most of these answers involve replacing or extending core with a lot of harder-to-maintain PHP, so while I'm grateful for the input and they provide ways to get the job done, they don't answer the question IMO. This "simple" theming problem has been the straw that broke my proverbial camel's d8 back, I'm afraid and I've ended up ditching a d8 project 3 months in to start from scratch in 7 - v disappointing but in 1 week I'd caught up completely :-| – artfulrobot May 6 '16 at 11:09
  • Thanks @artfulrobot, understood. It's a shame this didn't have a more satisfying conclusion. I'll let the bounty auto-award itself on whatever the community have voted on – Clive May 6 '16 at 11:14
  • twig is amazing. All the problem comes from the drupal theming system, which I think is a wrong approach. Just check how much extra work you have to do if you want to customize a simple link. This is disappointing. – Zoltán Süle Feb 3 '18 at 23:08

you can just add a render array to #title, like:

['#title'] = array('#markup' => '<i class="my-icons">yummy</i>' . $item['content']['#title']);

Old long answer:

You can override the link generator service

Create a module (alternative_linkgenerator), with an info file alternative_linkgenerator.info.yml

name: Alternative LinkGenerator
type: module
description: Adds alternative link generation.
core: 8.x

Make a file called alternative_linkgenerator.services.yml

    class: Drupal\alternative_linkgenerator\AlternativeLinkGenerator

Next is to create the class, add a folder named “src” (following PSR-4 autoloading standards) and within this a file called AlternativeLinkGenerator.php. (This is a 1:1 copy, you need to adapt the things for your)

class AlternativeLinkGenerator extends LinkGeneratorInterface {

   * The url generator.
   * @var \Drupal\Core\Routing\UrlGeneratorInterface
  protected $urlGenerator;

   * The module handler firing the route_link alter hook.
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
  protected $moduleHandler;

   * The renderer service.
   * @var \Drupal\Core\Render\RendererInterface
  protected $renderer;

   * Constructs a LinkGenerator instance.
   * @param \Drupal\Core\Routing\UrlGeneratorInterface $url_generator
   *   The url generator.
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The module handler.
   * @param \Drupal\Core\Render\RendererInterface $renderer
   *   The renderer service.
  public function __construct(UrlGeneratorInterface $url_generator, ModuleHandlerInterface $module_handler, RendererInterface $renderer) {
    $this->urlGenerator = $url_generator;
    $this->moduleHandler = $module_handler;
    $this->renderer = $renderer;

   * {@inheritdoc}
  public function generateFromLink(Link $link) {
    return $this->generate($link->getText(), $link->getUrl());

   * {@inheritdoc}
   * For anonymous users, the "active" class will be calculated on the server,
   * because most sites serve each anonymous user the same cached page anyway.
   * For authenticated users, the "active" class will be calculated on the
   * client (through JavaScript), only data- attributes are added to links to
   * prevent breaking the render cache. The JavaScript is added in
   * system_page_attachments().
   * @see system_page_attachments()
  public function generate($text, Url $url) {
    // Performance: avoid Url::toString() needing to retrieve the URL generator
    // service from the container.

    if (is_array($text)) {
      $text = $this->renderer->render($text);

    // Start building a structured representation of our link to be altered later.
    $variables = array(
      'text' => $text,
      'url' => $url,
      'options' => $url->getOptions(),

    // Merge in default options.
    $variables['options'] += array(
      'attributes' => array(),
      'query' => array(),
      'language' => NULL,
      'set_active_class' => FALSE,
      'absolute' => FALSE,

    // Add a hreflang attribute if we know the language of this link's url and
    // hreflang has not already been set.
    if (!empty($variables['options']['language']) && !isset($variables['options']['attributes']['hreflang'])) {
      $variables['options']['attributes']['hreflang'] = $variables['options']['language']->getId();

    // Ensure that query values are strings.
    array_walk($variables['options']['query'], function(&$value) {
      if ($value instanceof MarkupInterface) {
        $value = (string) $value;

    // Set the "active" class if the 'set_active_class' option is not empty.
    if (!empty($variables['options']['set_active_class']) && !$url->isExternal()) {
      // Add a "data-drupal-link-query" attribute to let the
      // drupal.active-link library know the query in a standardized manner.
      if (!empty($variables['options']['query'])) {
        $query = $variables['options']['query'];
        $variables['options']['attributes']['data-drupal-link-query'] = Json::encode($query);

      // Add a "data-drupal-link-system-path" attribute to let the
      // drupal.active-link library know the path in a standardized manner.
      if ($url->isRouted() && !isset($variables['options']['attributes']['data-drupal-link-system-path'])) {
        // @todo System path is deprecated - use the route name and parameters.
        $system_path = $url->getInternalPath();
        // Special case for the front page.
        $variables['options']['attributes']['data-drupal-link-system-path'] = $system_path == '' ? '<front>' : $system_path;

    // Remove all HTML and PHP tags from a tooltip, calling expensive strip_tags()
    // only when a quick strpos() gives suspicion tags are present.
    if (isset($variables['options']['attributes']['title']) && strpos($variables['options']['attributes']['title'], '<') !== FALSE) {
      $variables['options']['attributes']['title'] = strip_tags($variables['options']['attributes']['title']);

    // Allow other modules to modify the structure of the link.
    $this->moduleHandler->alter('link', $variables);

    // Move attributes out of options since generateFromRoute() doesn't need
    // them. Include a placeholder for the href.
    $attributes = array('href' => '') + $variables['options']['attributes'];

    // External URLs can not have cacheable metadata.
    if ($url->isExternal()) {
      $generated_link = new GeneratedLink();
      $attributes['href'] = $url->toString(FALSE);
    else {
      $generated_url = $url->toString(TRUE);
      $generated_link = GeneratedLink::createFromObject($generated_url);
      // The result of the URL generator is a plain-text URL to use as the href
      // attribute, and it is escaped by \Drupal\Core\Template\Attribute.
      $attributes['href'] = $generated_url->getGeneratedUrl();

    if (!SafeMarkup::isSafe($variables['text'])) {
      $variables['text'] = Html::escape($variables['text']);
    $attributes = new Attribute($attributes);
    // This is safe because Attribute does escaping and $variables['text'] is
    // either rendered or escaped.
    return $generated_link->setGeneratedLink('<a' . $attributes . '>' . $variables['text'] . '</a>');


Edit services.yml (normally at sites/default/services.yml in your Drupal 8 codebase) and add the following:

      alias: alternative_linkgenerator.link_generator

props goes here

  • Thanks, I'll give that a go. I only really want it to do that in certain contexts, though. Annoying to have to do a pure theming thing in php after the grand twig announcement. Thanks for your suggestion. – artfulrobot Apr 19 '16 at 11:58
  • That function does not seem to be called. I think that's for "link with separate title and URL elements". Neither does the template_preprocess_links thing get called (that's something specific, despite it's generic sounding name). – artfulrobot Apr 19 '16 at 12:11
  • template preprocess links is for link lists as far as I see, you always can enable twig debug to see what template / preprocess function was used for the output – rémy Apr 19 '16 at 12:40
  • Yes, neither of those is used for formatting links. Actually core/lib/Drupal/Core/Utility/LinkGenerator.php's generate() is used and this forces the text to be passed through Html::escape() so there is no way to do it without completely bypassing Drupal's link formatter. – artfulrobot Apr 19 '16 at 13:28
  • You can override this service like any other, see here tim.millwoodonline.co.uk/post/125163259445/… – rémy Apr 19 '16 at 20:18

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.