Drupal's bundle classes offer granular control over node URLs

Image
Photo by Graeme Worsfold on Unsplash

If you're reading this, you probably know that in Drupal a node can be accessed at its so-called canonical link at /node/{node id}. You also likely know that by enabling the core Path module, you can spice things up by setting a url alias. Further, with the contributed Pathauto and Redirect modules, you can make additional url magic happen automatically.

But what if you want a node to have an external url? Or what if you want it to have no url at all? These are problems we've run into at Horizontal. This post describes how we've used custom bundle classes to get highly granular control over a node's url.

The Requirements

Let's imagine building out a content type called a Feature. We're not especially interested in the full view mode of the Feature. In fact, we'll imagine that we've used Lullabot's micronode module such that an anonymous user would be denied access to the full view of the content. Instead, the Feature is intended to be rendered within other pages as a teaser, showing an image and a title and a bit of copy.

Our first set of requirements surround content authoring. The image and title on the teaser could be linked internally, externally, or not at all (though within a given teaser, they would link to the same place). The link destination needs to be controlled by the content author by entering (or not entering) a url or path into a link field we will call field_link.

A link widget with some help text.
The content author will see a link field on the edit form for the Feature. The Feature teaser should link to whatever they enter here.

A second set of requirements focus on site building and theming. Basically, we don't want the site builders or themers to have to do anything special to support the Feature teaser. We want to use the same teaser template that all our other content types use, and we don't want that template to contain a bunch of logic. We also want to avoid preprocessing either the Feature node or its fields. Finally, we want the configuration for the Feature teaser to be more-or-less like the configuration for any other teaser. In particular, if a field is configured to be "linked to content" in the Field UI, the resulting link should honor the value the content author entered for field_link.

Screenshot of media field configured in Field UI
A link to the content should result in a link to the location stored in field_link.

Now this is sounding pretty challenging, eh?

Bundle class to the rescue

It turns out this is very straightforward to achieve if we define a bundle class for the Feature content type. We do that with a hook_entity_bundle_info_alter():

function my_module_entity_bundle_info_alter(array &$bundles): void {
  if (isset($bundles['node']['feature'])) {
    $bundles['node']['feature']['class'] = 'Drupal\my_module\Entity\FeatureNode';
  }
}

And of course we have to add the bundle class itself. This example is a pretty simple class in that it overrides just two methods on the parent Node class: toUrl() and hasLinkTemplate().

<?php
namespace Drupal\my_module\Entity;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\Url;
use Drupal\node\Entity\Node;
use Drupal\link\Plugin\Field\FieldType\LinkItem;
class FeatureNode extends Node {
  /**
   * Use contents of field_link as canonical url.
   *
   * {@inheritdoc}
   */
  public function toUrl($rel = 'canonical', array $options = []) {
    if ($rel === 'canonical') {
      if (!empty($this->field_link->uri)) {
        return Url::fromUri($this->field_link->uri);
      }
      else {
        return new Url('<nolink>');
      }
    }
    return parent::toUrl($rel, $options);
  }
  /**
   * {@inheritdoc}
   *
   * This is important to avoid accidentally having pathauto delete all url aliases.
   * @see https://www.drupal.org/project/pathauto/issues/3367067
   */
  public function hasLinkTemplate($rel) {
    if ($rel === 'canonical') {
      return !empty($this->field_link->uri);
    }
    return parent::hasLinkTemplate($rel);
  }
}

There's unfortunately one special thing we probably have to do in the template. Since the Node title is not configurable through Field UI, it's probably hardcoded in any teaser template. We want to make sure to render the title as a link only if there is indeed a url to link it to. The resulting hunk of the teaser template might look like this:

<h3{{ title_attributes.addClass('node__title') }}>
  {% if url %}
  <a href="{{ url }}" rel="bookmark">
  {% endif %}
    {{ label }}
  {% if url %}
  </a>
  {% endif %}
</h3>

If you're using Layout Builder to configure the teaser or a contributed solution like Manage Display, you may not need that disappointing bit of Twig logic.

Profit

That's it! Everything just works!

The Fine Print

Ok, everything "just works" after patching a suite of bugs that this work exposed.

First, the Redirect module was causing a WSOD when a content author attempted to edit a Feature node with non-internal uris for field_link. We posted a patch to fix that.

Second, the Path module was causing a WSOD under similar circumstances, even when the URL alias was not on the Feature node edit form. Again, we posted a patch to keep us moving. (That one was close to being committed but has since stalled.)

Third, we ran into a bit of friction with Pathauto. When deleting a Feature with an external link we got a WSOD. We also ran into an issue if Pathauto happens to create an alias for a Feature with an empty field_link. The latter can be solved with careful configuration of Pathauto, but the former is a problem even if Pathauto is not enabled for Features. That said, we posted patches for both issues.

The takeaway is that we needed to apply this suite of four patches. Here's what the patches might look like in your composer.json:

"patches": {
    "drupal/core": {
        "Path module calls getInternalPath without checking if the url is routed": "https://www.drupal.org/files/issues/2023-05-03/3342398-18-D9.patch"
    },
    "drupal/redirect": {
        "redirect_form_node_form_alter calls getInternalPath on potentially unrouted url": "https://www.drupal.org/files/issues/2023-02-16/redirect-unrouted-3342409-2.patch"
    },
    "drupal/pathauto": {
        "deleteEntityPathAll calls getInternalPath on potentially unrouted url": "https://www.drupal.org/files/issues/2023-05-03/pathauto-unrouted-3357928-2.patch",
        "Alias should not get created if system path is <nolink>": "https://www.drupal.org/files/issues/2023-06-15/pathauto-nolink-3367043-2.patch"
    }
}

Closing thoughts

Given the patches required, maybe you're not ready to take the plunge. If you're looking for a drop-in solution for the problem described in this post, the Field Redirection module can come pretty close. 

However, we've had other sets of requirements surrounding a Node's url for which there is no appropriate contrib solution. In those cases, we ended up with different logic in our bundle class's toUrl() and hasLinkTemplate(), but this same set of four patches is required to avoid the gauntlet of WSODs.

So perhaps you look at the patches required as an opportunity to contribute to the Drupal Community! If the approach described in this post works for you, don't hesitate to jump into the issue queues and leave some reviews.