Drupal Views: Advanced Techniques for Custom Displays and REST Exports

Views is the most used module in the Drupal ecosystem and also the most underused — most developers barely scratch the surface of what it can do programmatically. This article covers the techniques that move you beyond the UI: contextual filters with argument validation, relationship chaining, REST and JSON:API-style exports, Views hooks, and generating view output programmatically in custom code.

Contextual Filters (Arguments) with Validation

Contextual filters accept URL arguments and filter the result set. The difference between a basic filter and a contextual filter is that contextual filters can be validated and have fallback behaviour when the argument is absent or invalid.

Via UI

  1. Add a Contextual Filter in the Views UI
  2. Under "When the filter value is NOT available" choose: "Display all results", "Provide default value", or "Display empty text / Return 404"
  3. Under "Validation" choose a validator (Numeric, Node, Taxonomy term, PHP, etc.)
  4. If validation fails: "Display empty / Return 404"

Programmatically Adding a Contextual Filter

<?php
use Drupal\views\Views;

// Execute a view with a contextual filter argument
$view = Views::getView('content_by_category');
if (!$view || !$view->access('page_1')) {
    return [];
}

$view->setDisplay('page_1');

// Arguments map 1:1 to contextual filters in the display order
// Here: first filter = taxonomy term ID
$view->setArguments([42]);

$view->preExecute();
$view->execute();

// Returns a renderable array
$build = $view->buildRenderable('page_1', [42]);
return $build;

Relationships: Joining Across Entity Types

Relationships in Views join a related entity or table so its fields become available. Common examples: a node's author (user entity), a node's taxonomy terms, a product's referenced product category.

Adding a Relationship in config/install YAML

View configuration lives in config/install/views.view.MY_VIEW.yml. A relationship block looks like:

display:
  default:
    display_options:
      relationships:
        uid:
          id: uid
          table: node_field_data
          field: uid
          relationship: none
          label: 'Author'
          required: false

Once a relationship is defined, all fields from that related entity become available under "Add fields" with the relationship label as prefix.

REST Export Display

The REST export display type outputs View results as JSON (or XML) directly, without a theme layer. It is ideal for feeding data to decoupled frontends, JavaScript components, or third-party integrations.

Setting Up via UI

  1. Add a new display: "REST export"
  2. Set the path (e.g., /api/v1/articles)
  3. Under "Format" → "Serializer" — choose "json" or leave as negotiated
  4. Under "Show" → "Fields" — add only the fields you want in the output
  5. Enable "Use pager" → "Mini" with a "items per page" exposed filter so clients can paginate

Controlling Output Fields

Each field in a REST export can have its "Label" set to control the JSON key name. Use "Rewrite results" → "Override the output of this field with custom text" combined with token replacement for computed values.

Adding Authentication

display:
  rest_export_1:
    display_options:
      access:
        type: role
        options:
          role:
            authenticated: authenticated

Or use the "Permission" access plugin to require a specific permission string.

Custom Row Plugin for REST Export

When you need complete control over the JSON shape of each row, write a custom Views row plugin:

<?php
// src/Plugin/views/row/ArticleSummaryRow.php

namespace Drupal\my_module\Plugin\views\row;

use Drupal\views\Plugin\views\row\RowPluginBase;
use Drupal\views\ResultRow;

/**
 * Renders a custom JSON structure for each article row.
 *
 * @ViewsRow(
 *   id = "article_summary_row",
 *   title = @Translation("Article Summary"),
 *   help = @Translation("Renders a compact article summary for API responses."),
 *   display_types = {"rest_export"}
 * )
 */
class ArticleSummaryRow extends RowPluginBase {

  public function render($row): array {
    /** @var \Drupal\node\NodeInterface $node */
    $node = $row->_entity;

    $imageUrl = null;
    if ($node->hasField('field_image') && !$node->field_image->isEmpty()) {
      $imageUrl = $node->field_image->entity?->createFileUrl(false);
    }

    return [
      'id'        => (int) $node->id(),
      'title'     => $node->label(),
      'url'       => $node->toUrl('canonical', ['absolute' => TRUE])->toString(),
      'published' => $node->getCreatedTime(),
      'image'     => $imageUrl,
      'summary'   => $node->get('body')->summary ?: mb_substr(strip_tags($node->get('body')->value ?? ''), 0, 200),
    ];
  }
}

Exposed Filters: Letting Users Filter Results

Exposed filters render as form elements in a block or embedded in the view. Use them for search, date ranges, and faceted navigation.

Key Settings per Exposed Filter

  • Identifier: the URL query parameter name (?status=1&type=article)
  • Required: whether the filter must be filled before results show
  • Remember: store the last-used value in the session
  • Grouped: present multiple pre-defined filter options as radio buttons or checkboxes

Altering Exposed Filter Form Programmatically

<?php
use Drupal\Core\Form\FormStateInterface;

/**
 * Implements hook_form_FORM_ID_alter().
 *
 * Alters the exposed filter form for the "article_listing" view.
 */
function my_module_form_views_exposed_form_alter(array &$form, FormStateInterface $form_state, string $form_id): void {
  $storage = $form_state->getStorage();
  if (($storage['view']->id() ?? '') !== 'article_listing') {
    return;
  }

  // Add a placeholder to the keyword field
  if (isset($form['title'])) {
    $form['title']['#attributes']['placeholder'] = t('Search articles…');
  }

  // Change the submit button label
  if (isset($form['actions']['submit'])) {
    $form['actions']['submit']['#value'] = t('Filter');
  }

  // Add a custom reset link
  $form['actions']['reset'] = [
    '#type'       => 'link',
    '#title'      => t('Reset'),
    '#url'        => \Drupal\Core\Url::fromRoute('<current>'),
    '#attributes' => ['class' => ['button', 'button--secondary']],
  ];
}

Views Hooks for Programmatic Customisation

hook_views_query_alter

<?php
/**
 * Implements hook_views_query_alter().
 *
 * Add an additional condition to any view on node_field_data.
 */
function my_module_views_query_alter(\Drupal\views\ViewExecutable $view, \Drupal\views\Plugin\views\query\QueryPluginBase $query): void {
  if ($view->id() === 'article_listing' && $view->current_display === 'page_1') {
    // Ensure only nodes with a populated image field are shown
    $query->addWhere('AND', 'node__field_image.field_image_target_id', NULL, 'IS NOT NULL');
  }
}

hook_views_pre_render

<?php
/**
 * Implements hook_views_pre_render().
 *
 * Inject additional data into each result row before rendering.
 */
function my_module_views_pre_render(\Drupal\views\ViewExecutable $view): void {
  if ($view->id() !== 'article_listing') {
    return;
  }
  foreach ($view->result as $row) {
    // Attach computed data to each row's entity
    if (isset($row->_entity)) {
      $row->_entity->computed_read_time = my_module_estimate_read_time($row->_entity);
    }
  }
}

hook_views_data and hook_views_data_alter

Add custom tables or computed fields to Views without a full plugin:

<?php
/**
 * Implements hook_views_data_alter().
 *
 * Expose a custom computed field to Views on node entities.
 */
function my_module_views_data_alter(array &$data): void {
  $data['node_field_data']['reading_time'] = [
    'title'  => t('Estimated reading time'),
    'help'   => t('Estimated minutes to read the body field.'),
    'field'  => [
      'id' => 'my_module_reading_time',  // Custom field handler plugin ID
    ],
  ];
}

Custom Views Field Plugin

<?php
// src/Plugin/views/field/ReadingTimeField.php

namespace Drupal\my_module\Plugin\views\field;

use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\ResultRow;

/**
 * Displays estimated reading time for a node.
 *
 * @ViewsField("my_module_reading_time")
 */
class ReadingTimeField extends FieldPluginBase {

  public function query(): void {
    // Join node__body table if not already joined
    $this->ensureMyTable();
    $this->addAdditionalFields(['nid']);
  }

  public function render(ResultRow $values): string {
    $node  = $values->_entity;
    $words = str_word_count(strip_tags($node->get('body')->value ?? ''));
    $mins  = (int) ceil($words / 200);

    return $this->t('@mins min read', ['@mins' => max(1, $mins)]);
  }
}

Generating Views Output in Custom Code

<?php
use Drupal\views\Views;

// Embed a view inside a custom block or controller
function my_module_get_recent_articles(int $limit = 5): array {
  $view = Views::getView('article_listing');
  if (!$view) {
    return [];
  }

  $view->setDisplay('block_1');
  $view->setItemsPerPage($limit);
  $view->execute();

  if (empty($view->result)) {
    return [];
  }

  return $view->buildRenderable('block_1', []);
}

// In a controller:
public function articleSidebar(): array {
  return [
    '#type'     => 'container',
    'heading'   => ['#markup' => '<h2>' . $this->t('Recent Articles') . '</h2>'],
    'articles'  => my_module_get_recent_articles(5),
  ];
}

Performance: Caching Views Results

Views has three caching mechanisms — configure per display under "Advanced" → "Caching":

PluginCache scopeUse when
NoneNo cacheDevelopment only
Tag-basedInvalidated when entities in results changeMost content views — default recommendation
Time-basedExpires after N secondsViews where you accept staleness (aggregation)

Tag-based caching is correct by default and integrates with Drupal's cache invalidation system. When a node in the result set is updated, Drupal invalidates the correct cache tags automatically.

Summary Checklist

  • Use Contextual Filters with validation and explicit "argument not present" behaviour — never leave it at the default "display all"
  • Add relationships to join across entity types; each relationship makes additional fields available for filtering and display
  • Use the REST export display for JSON APIs; set fields explicitly to control the output shape
  • Write a custom ViewsRow plugin when you need full control over the JSON structure per row
  • Alter exposed filter forms with hook_form_views_exposed_form_alter — use $storage['view']->id() to target specific views
  • Use hook_views_query_alter for conditions that cannot be expressed in the UI
  • Use hook_views_data_alter to expose custom tables and computed fields to Views
  • Call Views::getView() and buildRenderable() to embed views in custom blocks and controllers
  • Always use tag-based caching on content views — time-based caching leads to stale data on node save
  • Test REST export endpoints with curl -H "Accept: application/json" and verify pagination headers
Tags

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.