From our practice: a client wants a price range filter, but Drupal only offers a text field. Or need to display products from a custom table, but Views only works with entities. In such cases we develop custom plugins — and it's faster than it seems. Over 8 years of experience in Drupal allows us to implement such tasks turnkey in 2–3 days. The performance of a custom solution is 3 times higher than similar via Preprocess or template hacks. And if a REST API for external systems is needed — Views provides ready JSON export with CORS configuration in half an hour.
Why Views Requires Customization?
Standard Views covers 80% of tasks — filters, sorts, pagination, export. The remaining 20% are custom logic: filter by related entities with aggregation, output data from external API, non-standard row format. Without code these tasks are either unsolvable or require template hacks that kill performance. A custom plugin works 3 times faster than a similar solution via Preprocess. For example, a typical problem is INP (Interaction to Next Paint) above 200 ms when rendering a complex list. A proper row plugin with node tag caching reduces INP to 50 ms.
How to Create a Custom Filter in Half a Day?
The fastest way is hook_views_query_alter(). Suppose you need to filter products by price from and to. Pass GET parameters price_from and price_to, add conditions to the query. This does not require creating a plugin, but the logic remains in the module. For reusability we write a filter plugin with @ViewsFilter annotation. This approach ensures clean architecture and the ability to test the filter separately. We use PHP 8.3+ with typing and strict types — this reduces bugs at review stage.
What If Standard Views Doesn't Work with a Custom Table?
If your data is stored in a separate table (e.g., product_stats), Views won't see it by default. You need to register the table via hook_views_data(): specify base field, relationships to node_field_data, and data types for each column. After that the table becomes available in UI. Then write filter and sort plugins if the data type is non-standard. In one project we connected a custom table with stock and discounts exactly this way — it took 4 hours including tests.
How We Do It: Tech Stack and Case Study
We use Drupal 10/11, PHP 8.3+, Drush for config export. Example from our practice: an online store needed a custom block "Discounted products" with a filter by discount percentage and sorting by stock quantity. Standard Views cannot work with custom table product_stats. We registered the table via hook_views_data(), wrote a filter plugin for discount percentage and a sort plugin for stock. Result: block renders in 40 ms, cached by node tags.
Configuration of such View in YAML:
# views.view.discount_products.yml
id: discount_products
label: 'Discounted products'
module: views
description: ''
base_table: product_stats
base_field: id
display:
default:
display_options:
fields:
title:
id: title
table: node_field_data
field: title
relationship: nid
price:
id: price
table: node__field_price
field: field_price_value
discount_percent:
id: discount_percent
table: product_stats
field: discount_percent
stock:
id: stock
table: product_stats
field: stock_quantity
filters:
status:
value: '1'
discount_percent:
id: discount_percent
table: product_stats
field: discount_percent
operator: '>=,'
value: { min: '', max: '' }
expose:
operator: 'discount_percent_op'
identifier: discount
sorts:
stock_quantity:
order: ASC
pager:
type: some
options: { items_per_page: 10 }
relationships:
nid:
id: nid
table: product_stats
field: node_id
relationship: node_field_data
required: true
style:
type: html_list
row:
type: fields
block_1:
display_plugin: block
Hook for custom filter:
<?php
use Drupal\views\ViewExecutable;
use Drupal\views\Plugin\views\query\QueryPluginBase;
function my_module_views_query_alter(ViewExecutable $view, QueryPluginBase $query): void {
if ($view->id() !== 'discount_products') return;
$discount = \Drupal::request()->query->get('discount');
if (!empty($discount['min'])) {
$query->addWhereExpression(0, 'product_stats.discount_percent >= :min', [':min' => (float) $discount['min']]);
}
if (!empty($discount['max'])) {
$query->addWhereExpression(0, 'product_stats.discount_percent <= :max', [':max' => (float) $discount['max']]);
}
}
Row plugin for product card:
<?php
namespace Drupal\my_module\Plugin\views\row;
use Drupal\views\Plugin\views\row\RowPluginBase;
use Drupal\views\ResultRow;
/**
* @ViewsRow(
* id = "product_card",
* title = @Translation("Product card"),
* help = @Translation("Renders products as cards"),
* display_types = {"normal"},
* )
*/
class ProductCardRow extends RowPluginBase {
public function render(ResultRow $row): array {
$node = $row->_entity;
$view_builder = \Drupal::entityTypeManager()->getViewBuilder('node');
return [
'#theme' => 'product_card',
'#node' => $node,
'#title' => $node->getTitle(),
'#price' => $node->get('field_price')->value,
'#discount' => $row->discount_percent,
'#stock' => $row->stock_quantity,
'#url' => $node->toUrl()->toString(),
'#cache' => [
'tags' => $node->getCacheTags(),
'contexts' => ['user.roles'],
],
];
}
}
Programmatic use of View:
<?php
use Drupal\views\Views;
$view = Views::getView('discount_products');
$view->setDisplay('block_1');
$view->setExposedInput(['discount' => ['min' => 20, 'max' => 50]]);
$view->execute();
$results = $view->result;
foreach ($results as $row) {
$node = $row->_entity;
// render
}
Registration of custom table for Views:
<?php
function my_module_views_data(): array {
$data = [];
$data['product_stats']['table']['group'] = t('Product Stats');
$data['product_stats']['table']['base'] = [
'field' => 'id',
'title' => t('Product statistics'),
'help' => t('Custom table with discount and stock'),
];
$data['product_stats']['node_id'] = [
'title' => t('Node ID'),
'relationship' => [
'base' => 'node_field_data',
'base field' => 'nid',
'id' => 'standard',
'label' => t('Node'),
],
'filter' => ['id' => 'numeric'],
'sort' => ['id' => 'standard'],
'field' => ['id' => 'numeric'],
];
$data['product_stats']['discount_percent'] = [
'title' => t('Discount percent'),
'filter' => ['id' => 'numeric'],
'sort' => ['id' => 'standard'],
'field' => ['id' => 'numeric'],
];
$data['product_stats']['stock_quantity'] = [
'title' => t('Stock quantity'),
'filter' => ['id' => 'numeric'],
'sort' => ['id' => 'standard'],
'field' => ['id' => 'numeric'],
];
return $data;
}
Approach Comparison: hook vs plugin
| Criteria | hook_views_query_alter | Filter plugin |
|---|---|---|
| Development speed | 30 minutes | 2–4 hours |
| Reusability | No | Yes |
| Testability | Low | High |
| Caching | Default | Custom cache tags |
Work Process: From Analysis to Deployment
| Stage | Duration | Result |
|---|---|---|
| Analysis | 0.5–1 day | Technical specification with custom elements description |
| Design | 0.5 day | Data schema, filter prototypes |
| Implementation | 1–3 days | Plugins, configs, REST endpoints |
| Testing | 0.5 day | Load testing, compatibility with Drupal 10/11 |
| Deployment | 0.5 day | Config export, documentation |
What's Included in Custom Views Development
- Custom plugins for filters, sorts, fields, and rows
- REST Export display with CORS configuration
- Integration with custom database tables
- Documentation on usage and further customization
- Compatibility guarantee with latest Drupal versions
Timeline and Cost
Timeline depends on complexity: a simple custom filter — from half a day, a complex project with custom table and REST — 2–3 days. Cost is calculated individually. On average such customizations save up to 60% of budget compared to template hacks. Contact us — we will assess your project for free. Get a consultation on architecture and optimization.
For more on Views API see the official Drupal documentation. Order development — get a ready solution with compatibility and performance guarantee.







