Разработка кастомного модуля Drupal

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка кастомного модуля Drupal
Сложная
~3-5 рабочих дней
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Разработка кастомного модуля Drupal

Кастомный модуль — это способ добавить в Drupal поведение, которого нет в contrib. Архитектурно Drupal 10 это Symfony-приложение: сервисы в DI-контейнере, event subscribers, плагины, аннотации. Понимание этой архитектуры обязательно — без него получается набор хуков без структуры.

Структура модуля

web/modules/custom/my_module/
├── my_module.info.yml
├── my_module.module          # процедурные хуки (минимально)
├── my_module.install         # install/update/uninstall хуки
├── my_module.routing.yml     # маршруты
├── my_module.services.yml    # DI сервисы
├── my_module.links.menu.yml  # пункты меню
├── my_module.permissions.yml # разрешения
├── config/
│   ├── install/              # конфиг, создаваемый при установке
│   └── schema/               # схемы конфига для валидации
├── src/
│   ├── Controller/
│   │   └── ArticleController.php
│   ├── Form/
│   │   └── SettingsForm.php
│   ├── Plugin/
│   │   ├── Block/
│   │   │   └── RecentPostsBlock.php
│   │   └── Field/
│   │       ├── FieldFormatter/
│   │       └── FieldWidget/
│   ├── EventSubscriber/
│   │   └── RequestSubscriber.php
│   ├── Service/
│   │   └── ArticleService.php
│   └── Entity/
│       └── CustomEntity.php
└── templates/
    └── my-module-template.html.twig

my_module.info.yml

name: 'My Module'
type: module
description: 'Кастомный функционал проекта'
core_version_requirement: ^10
package: Custom
dependencies:
  - drupal:node
  - drupal:user
  - drupal:views

Контроллер и маршрут

# my_module.routing.yml
my_module.article_list:
  path: '/articles'
  defaults:
    _controller: '\Drupal\my_module\Controller\ArticleController::list'
    _title: 'Статьи'
  requirements:
    _permission: 'access content'

my_module.article_api:
  path: '/api/articles'
  defaults:
    _controller: '\Drupal\my_module\Controller\ArticleController::apiList'
  requirements:
    _permission: 'access content'
  options:
    _auth: ['basic_auth', 'cookie']
// src/Controller/ArticleController.php
namespace Drupal\my_module\Controller;

use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;

class ArticleController extends ControllerBase {

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
  ) {}

  public static function create(ContainerInterface $container): static {
    return new static(
      $container->get('entity_type.manager'),
    );
  }

  public function list(): array {
    $storage = $this->entityTypeManager->getStorage('node');
    $ids = $storage->getQuery()
      ->condition('type', 'article')
      ->condition('status', 1)
      ->sort('created', 'DESC')
      ->range(0, 20)
      ->accessCheck(TRUE)
      ->execute();

    $nodes = $storage->loadMultiple($ids);
    $view_builder = $this->entityTypeManager->getViewBuilder('node');

    return [
      '#theme' => 'item_list',
      '#items' => array_map(
        fn($node) => $view_builder->view($node, 'teaser'),
        $nodes
      ),
    ];
  }

  public function apiList(Request $request): JsonResponse {
    $page = (int) $request->query->get('page', 0);
    $limit = min((int) $request->query->get('limit', 10), 100);

    $storage = $this->entityTypeManager->getStorage('node');
    $query = $storage->getQuery()
      ->condition('type', 'article')
      ->condition('status', 1)
      ->sort('created', 'DESC')
      ->range($page * $limit, $limit)
      ->accessCheck(TRUE);

    $ids = $query->execute();
    $nodes = $storage->loadMultiple($ids);

    $data = array_map(function ($node) {
      return [
        'id' => $node->id(),
        'uuid' => $node->uuid(),
        'title' => $node->getTitle(),
        'created' => $node->getCreatedTime(),
        'url' => $node->toUrl()->setAbsolute()->toString(),
        'summary' => $node->get('body')->summary,
      ];
    }, $nodes);

    return new JsonResponse([
      'data' => array_values($data),
      'meta' => ['page' => $page, 'limit' => $limit],
    ]);
  }
}

Сервис и DI

# my_module.services.yml
services:
  my_module.article_service:
    class: Drupal\my_module\Service\ArticleService
    arguments:
      - '@entity_type.manager'
      - '@cache.default'
      - '@logger.factory'
// src/Service/ArticleService.php
namespace Drupal\my_module\Service;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;

class ArticleService {

  public function __construct(
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly CacheBackendInterface $cache,
    private readonly LoggerChannelFactoryInterface $loggerFactory,
  ) {}

  public function getFeaturedArticles(int $limit = 5): array {
    $cid = "my_module:featured:{$limit}";

    if ($cached = $this->cache->get($cid)) {
      return $cached->data;
    }

    $ids = $this->entityTypeManager->getStorage('node')
      ->getQuery()
      ->condition('type', 'article')
      ->condition('status', 1)
      ->condition('field_featured', 1)
      ->sort('created', 'DESC')
      ->range(0, $limit)
      ->accessCheck(FALSE)
      ->execute();

    $articles = $this->entityTypeManager->getStorage('node')->loadMultiple($ids);
    $data = array_values($articles);

    // Кэш с тегами — автоматически инвалидируется при изменении нод
    $tags = array_map(fn($node) => "node:{$node->id()}", $articles);
    $tags[] = 'node_list';
    $this->cache->set($cid, $data, CacheBackendInterface::CACHE_PERMANENT, $tags);

    return $data;
  }
}

Плагин Block

// src/Plugin/Block/RecentPostsBlock.php
namespace Drupal\my_module\Plugin\Block;

use Drupal\Core\Block\BlockBase;
use Drupal\Core\Block\BlockPluginInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * @Block(
 *   id = "my_module_recent_posts",
 *   admin_label = @Translation("Recent Posts"),
 *   category = @Translation("My Module"),
 * )
 */
class RecentPostsBlock extends BlockBase implements ContainerFactoryPluginInterface {

  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    private readonly ArticleService $articleService,
  ) {
    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('my_module.article_service'),
    );
  }

  public function build(): array {
    $articles = $this->articleService->getFeaturedArticles(
      $this->configuration['count'] ?? 5
    );

    return [
      '#theme' => 'my_module_recent_posts',
      '#articles' => $articles,
      '#cache' => [
        'tags' => ['node_list:article'],
        'contexts' => ['languages'],
        'max-age' => 3600,
      ],
    ];
  }

  public function blockForm($form, FormStateInterface $form_state): array {
    $form = parent::blockForm($form, $form_state);
    $form['count'] = [
      '#type' => 'number',
      '#title' => $this->t('Number of posts'),
      '#default_value' => $this->configuration['count'] ?? 5,
      '#min' => 1,
      '#max' => 20,
    ];
    return $form;
  }
}

Event Subscriber

// src/EventSubscriber/RequestSubscriber.php
namespace Drupal\my_module\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class RequestSubscriber implements EventSubscriberInterface {

  public static function getSubscribedEvents(): array {
    return [
      KernelEvents::REQUEST => ['onRequest', 100],
    ];
  }

  public function onRequest(RequestEvent $event): void {
    $request = $event->getRequest();
    // Логика для каждого входящего запроса
    if ($request->headers->get('X-Api-Version') === 'v2') {
      $request->attributes->set('api_version', 2);
    }
  }
}
# my_module.services.yml — добавляем
my_module.request_subscriber:
  class: Drupal\my_module\EventSubscriber\RequestSubscriber
  tags:
    - { name: event_subscriber }

Хуки в .module файле

Хуки оставляем только там, где нет альтернатив через плагины или сервисы:

// my_module.module

/**
 * Implements hook_node_presave().
 */
function my_module_node_presave(\Drupal\node\NodeInterface $node): void {
  if ($node->bundle() === 'article') {
    // Автоматически вычисляем время чтения
    $body = $node->get('body')->value ?? '';
    $words = str_word_count(strip_tags($body));
    $node->set('field_reading_time', (int) ceil($words / 200));
  }
}

/**
 * Implements hook_theme().
 */
function my_module_theme(): array {
  return [
    'my_module_recent_posts' => [
      'variables' => ['articles' => []],
      'template' => 'my-module-recent-posts',
    ],
  ];
}

Установка и обновление схемы

// my_module.install

function my_module_install(): void {
  // Создаём начальные данные
  \Drupal::configFactory()
    ->getEditable('my_module.settings')
    ->set('api_key', '')
    ->set('cache_ttl', 3600)
    ->save();
}

function my_module_update_10001(): void {
  // Добавляем новое поле к существующему типу контента
  $field_storage = \Drupal\field\Entity\FieldStorageConfig::create([
    'field_name' => 'field_reading_time',
    'entity_type' => 'node',
    'type' => 'integer',
  ]);
  $field_storage->save();

  \Drupal\field\Entity\FieldConfig::create([
    'field_storage' => $field_storage,
    'bundle' => 'article',
    'label' => 'Reading time (min)',
  ])->save();
}

Сроки

Простой модуль (контроллер + блок + хуки): 2–3 дня. Модуль с кастомными сущностями, плагинами, REST API, кэшированием: 8–15 дней в зависимости от сложности.