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
- Add a Contextual Filter in the Views UI
- Under "When the filter value is NOT available" choose: "Display all results", "Provide default value", or "Display empty text / Return 404"
- Under "Validation" choose a validator (Numeric, Node, Taxonomy term, PHP, etc.)
- 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: falseOnce 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
- Add a new display: "REST export"
- Set the path (e.g.,
/api/v1/articles) - Under "Format" → "Serializer" — choose "json" or leave as negotiated
- Under "Show" → "Fields" — add only the fields you want in the output
- 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: authenticatedOr 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":
| Plugin | Cache scope | Use when |
|---|---|---|
| None | No cache | Development only |
| Tag-based | Invalidated when entities in results change | Most content views — default recommendation |
| Time-based | Expires after N seconds | Views 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_alterfor conditions that cannot be expressed in the UI - Use
hook_views_data_alterto expose custom tables and computed fields to Views - Call
Views::getView()andbuildRenderable()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