php
ExistsSubqueryField.php
<?php
declare(strict_types=1);
namespace Drupal\my_module\Plugin\views\field;
use Drupal\views\Attribute\ViewsField;
use Drupal\views\Plugin\views\field\FieldPluginBase;
use Drupal\views\ResultRow;
/**
* Sortable yes/no column backed by an EXISTS subquery from views data.
*
* '@base' in the 'exists subquery' definition is replaced with the alias of
* the base table.
*/
#[ViewsField('exists_subquery')]
class ExistsSubquery extends FieldPluginBase {
public function query() {
$this->ensureMyTable();
$exists = 'EXISTS (' . str_replace('@base', $this->tableAlias, $this->definition['exists subquery']) . ')';
$this->field_alias = $this->query->addField(NULL, "CASE WHEN $exists THEN 1 ELSE 0 END", $this->realField);
}
public function clickSort($order) {
$this->query->addOrderBy(NULL, NULL, $order, $this->field_alias);
}
public function render(ResultRow $values) {
return $this->getValue($values) ? $this->t('yes') : $this->t('no');
}
}ExistsSubqueryFilter.php
<?php
declare(strict_types=1);
namespace Drupal\my_module\Plugin\views\filter;
use Drupal\views\Attribute\ViewsFilter;
use Drupal\views\Plugin\views\filter\BooleanOperator;
/**
* Yes/no filter backed by an EXISTS subquery from views data.
*/
#[ViewsFilter('exists_subquery')]
class ExistsSubquery extends BooleanOperator {
public function defaultExposeOptions() {
parent::defaultExposeOptions();
// BooleanOperator exposes as required without a stored value, which fails
// form validation. Default to "- Any -" instead.
$this->options['expose']['required'] = FALSE;
$this->options['value'] = 'All';
}
public function query() {
if ($this->value === 'All') {
return;
}
$this->ensureMyTable();
$exists = 'EXISTS (' . str_replace('@base', $this->tableAlias, $this->definition['exists subquery']) . ')';
$this->query->addWhereExpression($this->options['group'], empty($this->value) ? "NOT $exists" : $exists);
}
}MyModuleViewsHooks.php
<?php
declare(strict_types=1);
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\StringTranslation\StringTranslationTrait;
final class MyModuleViewsHooks {
use StringTranslationTrait;
#[Hook('views_data_alter')]
public function viewsDataAlter(array &$data): void {
$subquery = 'SELECT 1 FROM {node__field_related_content} ref WHERE ref.field_related_content_target_id = @base.nid';
$data['node_field_data']['is_referenced'] = [
'title' => $this->t('Is referenced'),
'field' => ['id' => 'exists_subquery', 'click sortable' => TRUE, 'exists subquery' => $subquery],
'filter' => ['id' => 'exists_subquery', 'label' => $this->t('Is referenced'), 'exists subquery' => $subquery],
];
}
}
08.10.2026 | Lothar Ferreira Neumann
How to add a sortable yes/no Views field and filter based on an EXISTS subquery
ECA.php
// web/modules/contrib/eca/src/Entity/Eca.php (simplified excerpt)
protected function isValidationDisabled(): bool {
return $this->request()->query->get('eca_validation', '') === 'off';
}
public function getPluginCollections(): array {
if ($this->isValidationDisabled()) {
return [];
}
// Otherwise every referenced event/condition/action plugin is
// instantiated here, throwing PluginNotFoundException if one
// of them no longer exists.
...
}hint.txt
# Delete URL — load this directly, don't click through from a listing page
/admin/config/workflow/eca/{eca_id}/delete?eca_validation=off
10.09.2026 | Lothar Ferreira Neumann
How to delete an ECA model that throws PluginNotFoundException for a missing event/action plugin
Factory.php
class SitesAliasPrefixListFactory implements AliasPrefixListInterface {
/**
* Cache of AliasPrefixList instances keyed by site ID.
*
* @var array
*/
protected array $instances = [];
/**
* Constructs a SitesAliasPrefixListFactory.
*
* @param \Drupal\sites\SiteProxyInterface $currentSite
* The current site service.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache backend.
* @param \Drupal\Core\Lock\LockBackendInterface $lock
* The lock backend.
* @param \Drupal\Core\State\StateInterface $state
* The state service.
* @param \Drupal\path_alias\AliasRepositoryInterface $aliasRepository
* The alias repository.
*/
public function __construct(
protected readonly SiteProxyInterface $currentSite,
protected readonly CacheBackendInterface $cache,
protected readonly LockBackendInterface $lock,
protected readonly StateInterface $state,
protected readonly AliasRepositoryInterface $aliasRepository,
) {}
/**
* Gets the AliasPrefixList instance for the current site.
*
* @return \Drupal\path_alias\AliasPrefixList
* The site-specific prefix list instance.
*/
public function getInstance(): AliasPrefixList {
$site_id = $this->currentSite->id();
if (!isset($this->instances[$site_id])) {
$this->instances[$site_id] = new AliasPrefixList(
'path_alias_prefix_list.' . $site_id,
$this->cache,
$this->lock,
$this->state,
$this->aliasRepository,
);
}
return $this->instances[$site_id];
}
24.07.2026 | Dominik Wille
What the hell are factories in drupal?
Set a new value to a variable if it's currently NULL.
some.php
$a ??= $b
15.05.2026 | Dominik Wille
Null Coalesce
8_5_new_features.php
<?php
/*** The Pipe Operator ***/
// Old way
strtolower(trim($input));
// New way
$input |> trim(...) |> strtolower(...);
/*** array_first() and array_last() ***/
$items = ['a' => 10, 'b' => 20, 'c' => 30];
echo array_first($items); // 10
echo array_last($items); // 30
/*** The #[NoDiscard] Attribute ***/
#[NoDiscard]
function validateToken($token) { ... }
validateToken($input); // PHP Warning: Return value of validateToken() must be used.
13.03.2026 | Peter Majmesku
PHP 8.5: New features
The New URI Extension
php_8.5_examples_uri_extension.php
<?php
##### URI Extension #####
// PHP 8.4 way
$components = parse_url('https://php.net/releases/8.4/en.php');
var_dump($components['host']);
// string(7) "php.net"
// PHP 8.5 URI Extension
use Uri\Rfc3986\Uri;
$uri = new Uri('https://php.net/releases/8.5/en.php');
var_dump($uri->getHost());
// string(7) "php.net"
The New Pipe Operator
php_8.5_pipe_operator.php
<?php
##### Pipe Operator #####
// PHP 8.4 way
$title = ' PHP 8.5 Released ';
$slug = strtolower(
str_replace('.', '',
str_replace(' ', '-',
trim($title)
)
)
);
var_dump($slug);
// string(15) "php-85-released"
// PHP 8.5 Pipe Operator
$title = ' PHP 8.5 Released ';
$slug = $title
|> trim(...)
|> (fn($str) => str_replace(' ', '-', $str))
|> (fn($str) => str_replace('.', '', $str))
|> strtolower(...);
var_dump($slug);
// string(15) "php-85-released"
21.11.2025 | Peter Majmesku
PHP 8.5 New Features: Pipe Operator and URI Extension
my_module.module
use Drupal\views\ViewExecutable;
use Drupal\my_module\Access\NodePageAccessControlHandler;
use Drupal\my_module\Access\RestrictedMediaAccessControlHandler;
/**
* Implements hook_entity_type_alter().
*/
function my_module_entity_type_alter(array &$entity_types) {
if (isset($entity_types['node'])) {
$entity_types['node']->setAccessClass(NodePageAccessControlHandler::class);
}
if (isset($entity_types['media'])) {
$entity_types['media']->setAccessClass(RestrictedMediaAccessControlHandler::class);
}
}
/**
* Implements hook_views_post_execute().
*/
function my_module_views_post_execute(ViewExecutable $view) {
// Only execute if we are on the right view display.
if ($view->id() === 'MY_VIEW_ID' && $view->current_display === 'MY_VIEW_DISPLAY' ) {
// Mark this view results as token-allowed in the session for a short time.
$ttl = 3600; // 1 hour; adjust to taste.
$now = \Drupal::time()->getRequestTime();
$session = \Drupal::service('request_stack')->getCurrentRequest()->getSession();
$grants = (array) $session->get('my_module.token_mids', []);
foreach ($view->result as $item) {
$grants[(int) $item->mid] = $now + $ttl;
}
$session->set('my_module.token_mids', $grants);
}
}NodePageAccessControlHandler.php
namespace Drupal\my_module\Access;
/**
* Override Node Access Control Handler.
*/
class NodePageAccessControlHandler extends NodeAccessControlHandler {
...
/**
* {@inheritdoc}
*/
public function access(EntityInterface $entity, $operation, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
if ($operation === 'view') {
// Mark this node as token-allowed in the session for a short time.
$ttl = 3600; // 1 hour; adjust to taste.
$now = $this->time->getRequestTime();
$session = $this->requestStack->getSession();
$grants = (array) $session->get('my_module.token_nids', []);
$grants[(int) $entity->id()] = $now + $ttl;
$session->set('my_module.token_nids', $grants);
}
}
RestrictedMediaAccessControlHandler.php
namespace Drupal\my_module\Access;
/**
* Override Media Access Control Handler.
*/
class RestrictedMediaAccessControlHandler extends MediaAccessControlHandler {
/**
* {@inheritdoc
*
* Restrict anonymous view of Media (bundle "restricted_file") to cases where
* the user can view the host Node (via paragraph field
* 'field_download_restricted' or a view listing media entities),
* OR they have a valid short-lived token grant for that Node stored in session.
*/
public function access(EntityInterface $entity, $operation, ?AccountInterface $account = NULL, $return_as_object = FALSE) {
if (
$operation !== 'view'
|| !$account
|| $entity->bundle() !== 'restricted_file'
) {
return parent::access($entity, $operation, $account, $return_as_object);
}
$paragraphs = $this->entityTypeManager
->getStorage('paragraph')
->loadByProperties(['field_download_restricted' => $entity->id()]);
// Check if we have a media entity id and a cookie for it does exist.
$media_entity = NULL;
if (array_key_exists($entity->id(), $mgrants = (array) $this->requestStack->getSession()->get('my_module.token_mids', []))) {
$media_entity = $this->entityTypeManager
->getStorage('media')
->loadByProperties(['mid' => $entity->id()]);
$now = $this->time->getRequestTime();
// Session-granted access via token.
$mid = (int) $entity->id();
if (!empty($mgrants[$mid]) && $mgrants[$mid] >= $now) {
return AccessResult::allowed()
->addCacheableDependency($entity)
->addCacheableDependency(reset($media_entity))
// Important for anon/session-based grants.
->mergeCacheMaxAge(0);
}
}
$result = AccessResult::forbidden()
->addCacheableDependency($entity)
->cachePerPermissions();
if (!$paragraphs && !$media_entity) {
return $result;
}
// Read session grants set by my_module_node_access().
$now = $this->time->getRequestTime();
$grants = (array) $this->requestStack->getSession()->get('my_module.token_nids', []);
foreach ($paragraphs as $paragraph) {
if ($paragraph instanceof ParagraphInterface) {
$result = $result->addCacheableDependency($paragraph);
}
$node = $this->getAncestorEntity($paragraph);
if (!$node instanceof NodeInterface) {
continue;
}
// Always add the node as a dependency.
$result = $result->addCacheableDependency($node);
// Session-granted access via token.
$nid = (int) $node->id();
if (!empty($grants[$nid]) && $grants[$nid] >= $now) {
return AccessResult::allowed()
->addCacheableDependency($entity)
->addCacheableDependency($node)
// Important for anon/session-based grants.
->mergeCacheMaxAge(0);
}
// Existing rule: inherit from node view access.
if ($node->access('view', $account)) {
return AccessResult::allowed()
->addCacheableDependency($entity)
->addCacheableDependency($node)
->cachePerPermissions();
}
return $result;
}
}
}
13.11.2025 | Michael Ebert
Manage access to private files in paragraphs or views on restricted nodes
CustomWidget.php
public static function isApplicable(FieldDefinitionInterface $field_definition) {
if (isset($field_definition->getDisplayOptions('form')['type'])) {
return $field_definition->getDisplayOptions('form')['type'] == 'slot_content_weights';
}
return FALSE;
}
13.11.2025 | Dominik Wille
Don't list a widget for a field type.
19.12.2025 | Michael Ebert