
How to Alter and Extend Views in Drupal 10
Views is Drupal's query builder. It handles content listings, search results, admin screens, and dozens of other display tasks across most Drupal sites. While the administrative UI covers many use cases, you'll eventually hit situations where you need programmatic control (filtering results based on complex business logic, adding computed fields, or modifying queries in ways the UI doesn't support).
This guide covers the main techniques for altering and extending Views in Drupal 10: lifecycle hooks for modifying queries and output, custom plugins for reusable functionality, and Views data hooks for exposing custom tables and fields. The approaches here work across Drupal 8, 9, 10, and 11, since the Views API has remained stable through these versions.
Prerequisites
Before working with Views programmatically, you should have:
- A working Drupal 10 installation with Views enabled (it's in core)
- A custom module where you'll place your code
- Basic familiarity with Drupal's hook system and plugin architecture
- Understanding of how Views queries work (SQL-based by default)
- PHP 8.1 (required for Drupal 10)
You'll also want Drush installed for cache clearing, since Views discovery is cached aggressively. After adding any new hooks or plugins, run drush cr to see your changes.
Understanding the Views Lifecycle
Views processes content through a predictable sequence: build, query, execute, render. Each phase has corresponding hooks where you can intervene:
Configuration layer
- hook_views_data() - Declare tables and fields to Views
- hook_views_data_alter() - Modify other modules' Views data
Build and query layer
- hook_views_pre_view() - Early alteration before display attachment
- hook_views_pre_build() - After displays attached, before query construction
- hook_views_query_alter() - Modify the database query directly
- hook_views_pre_execute() - Final changes before query runs
Result and render layer
- hook_views_post_execute() - Immediately after query execution
- hook_views_pre_render() - Before render array construction
- hook_views_post_render() - Final output modification
Choosing the right hook matters. Use hook_views_query_alter() for SQL-level changes, hook_views_pre_render() for entity manipulation, and hook_views_post_render() for markup adjustments.
Step 1: Altering View Queries
The most common programmatic need is modifying the database query behind a View. hook_views_query_alter() gives you direct access to the SQL query object.
Here's a practical example that restricts a View to only show published content from a specific vocabulary term:
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\query\Sql;
/**
* Implements hook_views_query_alter().
*/
function mymodule_views_query_alter(ViewExecutable $view, Sql $query) {
// Always check view and display IDs to avoid affecting other Views.
if ($view->id() === 'article_listing' && $view->current_display === 'page_1') {
// Add a condition for published content only.
$query->addWhere(0, 'node_field_data.status', 1, '=');
// Join to taxonomy and filter by term.
$definition = [
'table' => 'taxonomy_index',
'field' => 'nid',
'left_table' => 'node_field_data',
'left_field' => 'nid',
];
$query->addRelationship('taxonomy_index', $definition, 'node_field_data');
$query->addWhere(1, 'taxonomy_index.tid', 5, '=');
}
}Our experience shows that the most frequent mistake here is forgetting to check the View ID, which causes your alterations to affect every View on the site. Always scope your changes narrowly using both $view->id() and $view->current_display.
The $query object provides several useful methods:
- addWhere($group, $field, $value, $operator) - Add conditions
- addRelationship($alias, $join, $base) - Add table joins
- addOrderBy($table, $field, $order) - Add sorting
- addGroupBy($field) - Add grouping
For context-aware filtering, you can access the current user, request parameters, or any injected service within the hook:
function mymodule_views_query_alter(ViewExecutable $view, Sql $query) {
if ($view->id() === 'user_content') {
$current_user = \Drupal::currentUser();
if (!$current_user->hasPermission('view all content')) {
// Restrict to user's own content.
$query->addWhere(0, 'node_field_data.uid', $current_user->id(), '=');
}
}
}Step 2: Modifying Results and Rendering
When you need to change what users see without altering the underlying query, use the result and render hooks.
Post-execute manipulation
hook_views_post_execute() runs immediately after the query. You can modify $view->result directly:
use Drupal\views\ViewExecutable;
/**
* Implements hook_views_post_execute().
*/
function mymodule_views_post_execute(ViewExecutable $view) {
if ($view->id() === 'featured_content') {
// Add computed data to each result row.
foreach ($view->result as $index => $row) {
$row->custom_score = calculate_relevance_score($row->_entity);
}
}
}Pre-render entity manipulation
hook_views_pre_render() is useful for modifying entities before they become HTML:
/**
* Implements hook_views_pre_render().
*/
function mymodule_views_pre_render(ViewExecutable $view) {
if ($view->id() === 'article_listing') {
foreach ($view->result as $row) {
if (!empty($row->_entity) && $row->_entity->bundle() === 'article') {
// Mark featured articles in the title.
if ($row->_entity->get('field_featured')->value) {
$current_title = $row->_entity->label();
$row->_entity->setTitle($current_title . ' ★');
}
}
}
}
}Post-render output changes
For final markup adjustments, use hook_views_post_render():
/**
* Implements hook_views_post_render().
*/
function mymodule_views_post_render(ViewExecutable $view, array &$output, &$cache) {
if ($view->id() === 'product_grid') {
$output['#prefix'] = '<div class="product-grid-wrapper">';
$output['#suffix'] = '</div>';
$output['#attached']['library'][] = 'mymodule/product-grid';
}
}Step 3: Creating Custom Field Plugins
For reusable field functionality, create a custom Views field plugin. These live in src/Plugin/views/field/ within your module.
<?php
namespace Drupal\mymodule\Plugin\views\field;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\ResultRow;
/**
* A custom Views field that displays entity status and freshness.
*
* @ViewsField("entity_status_freshness")
*/
class EntityStatusFreshness extends FieldPluginBase {
/**
* {@inheritdoc}
*/
public function query() {
// Leave empty to avoid altering the query.
}
/**
* {@inheritdoc}
*/
public function render(ResultRow $values) {
$entity = $this->getEntity($values);
if (!$entity) {
return '';
}
$status = $entity->isPublished() ? 'published' : 'draft';
$age = time() - $entity->getCreatedTime();
if ($age < 86400) {
$freshness = 'new';
}
elseif ($age < 604800) {
$freshness = 'recent';
}
else {
$freshness = 'archived';
}
return [
'#markup' => "<span class=\"status-{$status} freshness-{$freshness}\">"
. $this->t('@status / @fresh', [
'@status' => ucfirst($status),
'@fresh' => ucfirst($freshness),
])
. "</span>",
];
}
}To make this field available in Views, register it through hook_views_data_alter():
/**
* Implements hook_views_data_alter().
*/
function mymodule_views_data_alter(array &$data) {
$data['node_field_data']['content_status_indicator'] = [
'title' => t('Content status indicator'),
'help' => t('Shows publication status and content freshness.'),
'field' => [
'id' => 'content_status_indicator',
],
];
}After clearing cache, this field appears in the Views UI when building Views based on content.
Step 4: Creating Custom Filter Plugins
Custom filters let you add filtering logic that the standard UI doesn't support. Here's a filter that restricts content based on user role relationships:
<?php
namespace Drupal\mymodule\Plugin\views\filter;
use Drupal\views\Plugin\views\filter\FilterPluginBase;
use Drupal\Core\Form\FormStateInterface;
/**
* Custom filter for role-based content access.
*
* @ViewsFilter("role_based_content_filter")
*/
class RoleBasedContentFilter extends FilterPluginBase {
/**
* {@inheritdoc}
*/
protected function defineOptions() {
$options = parent::defineOptions();
$options['moderation_level'] = ['default' => 'own'];
return $options;
}
/**
* {@inheritdoc}
*/
public function buildOptionsForm(&$form, FormStateInterface $form_state) {
parent::buildOptionsForm($form, $form_state);
$form['moderation_level'] = [
'#type' => 'select',
'#title' => $this->t('Moderation scope'),
'#options' => [
'own' => $this->t('Own content only'),
'department' => $this->t('Department content'),
'all' => $this->t('All content'),
],
'#default_value' => $this->options['moderation_level'],
];
}
/**
* {@inheritdoc}
*/
public function query() {
$this->ensureMyTable();
$current_user = \Drupal::currentUser();
switch ($this->options['moderation_level']) {
case 'own':
$this->query->addWhere(
$this->options['group'],
'node_field_data.uid',
$current_user->id(),
'='
);
break;
case 'department':
// Get department IDs for current user.
$departments = $this->getUserDepartments($current_user->id());
if (!empty($departments)) {
$this->query->addWhere(
$this->options['group'],
'node__field_department.field_department_target_id',
$departments,
'IN'
);
}
break;
case 'all':
// No filtering.
break;
}
}
/**
* Gets department IDs for a user.
*/
protected function getUserDepartments($uid) {
// Implementation depends on your site structure.
return [];
}
}Register the filter similarly to the field plugin:
function mymodule_views_data_alter(array &$data) {
$data['node_field_data']['user_moderation_scope'] = [
'title' => t('User moderation scope'),
'help' => t('Filters content based on user moderation permissions.'),
'filter' => [
'id' => 'user_moderation_scope',
],
];
}Step 5: Exposing Custom Data to Views
To make custom database tables available in Views, implement hook_views_data():
/**
* Implements hook_views_data().
*/
function mymodule_views_data() {
$data = [];
$data['mymodule_metrics'] = [
'table' => [
'group' => t('Content metrics'),
'base' => [
'field' => 'id',
'title' => t('Content metrics'),
'help' => t('Custom metrics data for content.'),
],
// Join to nodes.
'join' => [
'node_field_data' => [
'left_field' => 'nid',
'field' => 'entity_id',
],
],
],
'id' => [
'title' => t('Metric ID'),
'field' => ['id' => 'numeric'],
'filter' => ['id' => 'numeric'],
'sort' => ['id' => 'standard'],
],
'view_count' => [
'title' => t('View count'),
'field' => ['id' => 'numeric'],
'filter' => ['id' => 'numeric'],
'sort' => ['id' => 'standard'],
],
'engagement_score' => [
'title' => t('Engagement score'),
'field' => ['id' => 'numeric'],
'filter' => ['id' => 'numeric'],
'sort' => ['id' => 'standard'],
],
];
return $data;
}This makes your custom table's fields available as Views fields, filters, and sort criteria, with automatic joining to nodes.
Common Mistakes to Avoid
Not scoping hooks to specific Views: Every hook should check $view->id() and usually $view->current_display before making changes. Unscoped hooks affect every View on your site.
Forgetting cache clears: Views plugin discovery and hook registrations are cached. Run drush cr after adding or modifying any Views code.
Wrong namespace or annotation: Plugins must live in the correct directory (src/Plugin/views/field/, src/Plugin/views/filter/, etc.) with matching annotations. A misplaced file won't be discovered.
Heavy processing in render hooks: We recommend keeping render-phase hooks lightweight. Move complex business logic into services that you call from hooks rather than implementing everything inline.
Ignoring query performance: Adding multiple joins or complex conditions in hook_views_query_alter() can slow down page loads significantly on large datasets. Test with production-sized data and check query performance.
Modifying entities directly: In hook_views_pre_render(), changes to $row->_entity persist in cache. If you're modifying entity properties for display, consider whether those changes might appear elsewhere unexpectedly.
Testing and Verification
After implementing Views alterations, verify they work correctly:
Check hook execution
Add temporary logging to confirm your hooks fire:
function mymodule_views_query_alter(ViewExecutable $view, Sql $query) {
\Drupal::logger('mymodule')->notice('Query alter fired for @view', [
'@view' => $view->id(),
]);
// Your actual implementation...
}Inspect the generated query
Enable the Devel module and use dpq() to see the SQL:
function mymodule_views_query_alter(ViewExecutable $view, Sql $query) {
if ($view->id() === 'my_view') {
// After your modifications.
dpq($query);
}
}Test plugin discovery
Check that your custom plugins appear in the Views UI. If they don't:
- Verify the file is in the correct src/Plugin/views/[type]/ directory
- Check that the namespace matches the file path
- Confirm the annotation is correct (@ViewsField, @ViewsFilter, etc.)
- Clear all caches
Verify with different users
If your alterations involve permissions or user context, test with accounts that have different roles to confirm the logic works correctly across permission levels.
Check caching behavior
Views caches results aggressively. Test that your dynamic alterations work correctly when cache is enabled, or configure appropriate cache contexts/tags if results vary by user or context.
Conclusion
Programmatic Views control in Drupal 10 comes down to choosing the right intervention point. Use hook_views_query_alter() for database-level filtering, result and render hooks for display modifications, and custom plugins for reusable functionality. The Views API has stayed stable across Drupal 8 through 11, so these techniques will serve you well for years.
Working with teams has taught us that the best Views customizations are the ones that stay focused and well-documented. A single hook doing one thing clearly beats a complex hook trying to handle multiple scenarios. When your needs grow beyond what hooks can elegantly handle, custom plugins with proper dependency injection give you testable, maintainable code.
If you're building complex editorial workflows or need Views to handle business logic beyond what the UI supports, having a clear plan for where each piece of logic lives makes a significant difference. We work with teams to design Views architectures that handle their specific content requirements without creating maintenance headaches down the road. Get in touch if you'd like to talk through your approach.
