Developing Custom Drupal Blocks
Blocks in Drupal are plugins placed in theme regions. Two types: Content blocks (created via UI by editors) and Plugin blocks (code). Custom plugin blocks are what developers write when dynamic logic is needed: displaying latest posts, form widget, banner from config.
Block Plugin Anatomy
// src/Plugin/Block/LatestNewsBlock.php
namespace Drupal\my_module\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* @Block(
* id = "my_module_latest_news",
* admin_label = @Translation("Latest News"),
* category = @Translation("My Module"),
* context_definitions = {
* "node" = @ContextDefinition("entity:node", required = FALSE, label = @Translation("Current node"))
* }
* )
*/
class LatestNewsBlock extends BlockBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
private readonly EntityTypeManagerInterface $entityTypeManager,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration, $plugin_id, $plugin_definition,
$container->get('entity_type.manager'),
);
}
/**
* Block configuration form (shown in Layout Builder / Block UI).
*/
public function blockForm($form, FormStateInterface $form_state): array {
$form = parent::blockForm($form, $form_state);
$config = $this->getConfiguration();
$form['count'] = [
'#type' => 'number',
'#title' => $this->t('Number of items'),
'#default_value' => $config['count'] ?? 4,
'#min' => 1,
'#max' => 20,
];
$form['exclude_current'] = [
'#type' => 'checkbox',
'#title' => $this->t('Exclude current node'),
'#default_value' => $config['exclude_current'] ?? TRUE,
];
return $form;
}
public function blockSubmit($form, FormStateInterface $form_state): void {
$this->configuration['count'] = $form_state->getValue('count');
$this->configuration['exclude_current'] = $form_state->getValue('exclude_current');
}
public function build(): array {
$config = $this->getConfiguration();
$count = $config['count'] ?? 4;
$storage = $this->entityTypeManager->getStorage('node');
$query = $storage->getQuery()
->condition('type', 'news')
->condition('status', 1)
->sort('created', 'DESC')
->range(0, $count)
->accessCheck(TRUE);
// Exclude current node if option enabled
if ($config['exclude_current'] ?? TRUE) {
try {
$current_node = $this->getContextValue('node');
if ($current_node && $current_node->id()) {
$query->condition('nid', $current_node->id(), '<>');
}
} catch (\Exception $e) {
// Context not available — don't exclude
}
}
$ids = $query->execute();
if (empty($ids)) {
return ['#markup' => ''];
}
$nodes = $storage->loadMultiple($ids);
$view_builder = $this->entityTypeManager->getViewBuilder('node');
return [
'#theme' => 'my_module_news_list',
'#items' => array_map(fn($n) => $view_builder->view($n, 'teaser'), $nodes),
// Cache: invalidated when any news node changes
'#cache' => [
'tags' => Cache::mergeTags(['node_list:news'], $this->getCacheTags()),
'contexts' => ['route', 'user.roles'],
'max-age' => Cache::PERMANENT,
],
];
}
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), ['node_list:news']);
}
}
Content Blocks — UI Management
Content Blocks (Block content entities) created by editors in /admin/content/block. Create custom block type in /admin/structure/block-content/types.
Programmatic block type creation:
use Drupal\block_content\Entity\BlockContentType;
use Drupal\block_content\Entity\BlockContent;
// Block type
$block_type = BlockContentType::create([
'id' => 'promo_banner',
'label' => 'Promo Banner',
'description' => 'Promotional banner with image and button',
]);
$block_type->save();
// Add body (standard body field)
block_content_add_body_field($block_type->id());
// Programmatically create Content Block
$block = BlockContent::create([
'type' => 'promo_banner',
'info' => 'Summer Sale',
'body' => ['value' => '<p>20% discount until July 31</p>', 'format' => 'full_html'],
'field_image' => [['target_id' => 42]],
'field_button_text' => 'Learn More',
'field_button_url' => ['uri' => 'internal:/promo'],
]);
$block->save();
Layout Builder — inline block editing
With layout_builder enabled, editors place blocks directly on page. Custom plugin block automatically appears in available list.
Enable Layout Builder for content type:
drush en layout_builder layout_discovery -y
In /admin/structure/types/manage/{type}/display enable Layout Builder.
Block with Form
Block rendering a form:
use Drupal\Core\Form\FormBuilderInterface;
/**
* @Block(
* id = "my_module_search_block",
* admin_label = @Translation("Search Block"),
* )
*/
class SearchBlock extends BlockBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration, $plugin_id, $plugin_definition,
private readonly FormBuilderInterface $formBuilder,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
public static function create(ContainerInterface $container, ...$args): static {
return new static(...$args, $container->get('form_builder'));
}
public function build(): array {
return $this->formBuilder->getForm(\Drupal\my_module\Form\SearchForm::class);
}
public function getCacheContexts(): array {
return Cache::mergeContexts(parent::getCacheContexts(), ['url.query_args:q']);
}
}
Programmatic Block Placement
use Drupal\block\Entity\Block;
// Create block instance and place in region
$block = Block::create([
'id' => 'latest_news_sidebar',
'plugin' => 'my_module_latest_news',
'theme' => 'my_theme',
'region' => 'sidebar_first',
'weight' => -5,
'settings' => [
'label' => 'Latest News',
'label_display' => 'visible',
'count' => 3,
'exclude_current' => TRUE,
],
'visibility' => [
'node_type' => [
'id' => 'node_type',
'bundles' => ['article' => 'article', 'news' => 'news'],
'negate' => FALSE,
'context_mapping' => ['node' => '@node.node_route_context:node'],
],
],
]);
$block->save();
YAML Configuration
After placing blocks via UI export to config:
# block.block.latest_news_sidebar.yml
id: latest_news_sidebar
theme: my_theme
region: sidebar_first
weight: -5
plugin: my_module_latest_news
settings:
id: my_module_latest_news
label: 'Latest News'
label_display: visible
count: 3
exclude_current: true
visibility:
node_type:
id: node_type
bundles:
article: article
news: news
negate: false
Block Caching
Drupal has complex caching system. For blocks:
-
cache_tags— invalidation by tags (when data changes) -
cache_contexts— cache variations (by role, URL, language) -
max-age— time-based limit
public function getCacheMaxAge(): int {
// Block not cached at all — for real-time
return 0;
// Cache for hour
return 3600;
// Permanent cache with tag invalidation
return Cache::PERMANENT;
}
Mistake — return 0 for all blocks. This kills performance. Correct — use tags and contexts, set max-age = Cache::PERMANENT.
Timelines
One custom plugin block with configuration: 1 day. Set of blocks with forms, Layout Builder integration, complex caching: 3–5 days.







