Розробка кастомного модуля 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: 'Мій модуль'
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("Останні Посади"),
 *   category = @Translation("Мій модуль"),
 * )
 */
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('Кількість посадів'),
      '#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' => 'Час читання (хв)',
  ])->save();
}

Терміни

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