Published:


Tópicos em desenvolvimento de módulos para Drupal Permalink

Coleção de dicas para desenvolver módulos para Drupal, nada que substitua a documentação oficial.

    Criando uma instância Drupal para desenvolvimento com Debian 10

    Pacotes básicos para subirmos uma instância de drupal com sqlite3 no debian 10:

    apt-get install php php-common php-cli php-gd php-curl php-xml php-mbstring php-sqlite3 sqlite3

    Instalação do composer globalmente:

    curl -s https://getcomposer.org/installer | php
    sudo mv composer.phar /usr/local/bin/composer

    Criando uma instalação limpa para começar a desenvolver. Será criado um diretório chamado drupal-dev, sendo usuário/senha igual a admin/admin:

    composer create-project drupal/recommended-project:8.x drupal-dev
    cd drupal-dev
    composer require drupal/console
    composer require drush/drush:8.x
    ./vendor/bin/drush site-install standard \
      --db-url=sqlite://sites/default/files/.ht.sqlite \
      --site-name="Ambiente Dev" \
      --site-mail="dev@locahost" \
      --account-name="admin" \
      --account-pass="admin" \
      --account-mail="dev@localhost" --yes

    Normalmente, eu ignoro as pastas vendor, web e drush no gitignore.

    Subindo um server local para desenvolvimento:

    ./vendor/bin/drupal serve -v

    Caso precise zerar o banco e começar tudo novamente:

    rm web/sites/default/files/.ht.sqlite*

    Criando um módulo

    Todos exemplos serão baseados em um módulo fictício chamado tofu. Para o drupal reconhecer nosso módulo, isto é, o mesmo aparecer na lista de módulos para serem habilitados, necessitamos criar uma pasta chamada tofu com o arquivo tofu.info.yml, o qual contém informações básicas do módulo. O comando abaixo se encarrega de criar o módulo tofu:

    ./vendor/bin/drupal generate:module  \
      --module="tofu"  \
      --machine-name="tofu"  \
      --module-path="modules"  \
      --description="Módulo Tofu"  \
      --core="8.x"  \
      --no-interaction

    Rotas

    Criando rota e controller

    As entradas de rotas são definidas em tofu.routing.yml. O comando a seguir vai gerar o controller TofuController com um método chamado index(), assim como uma rota /tofu apontando para esse método:

    ./vendor/bin/drupal generate:controller  \
      --module="tofu"  \
      --class="TofuController"  \
      --routes='"title":"index", "name":"tofu.index", "method":"index", "path":"/tofu"'  \
      --no-interaction

    A entrada criada em tofu.routing.yml tem a forma:

    tofu.index:
      path: '/index'
      defaults:
        _controller: '\Drupal\tofu\Controller\TofuController::index'
      requirements:
        _permission: 'access content'

    Rota com parâmetros

    Se no método index() do controller quisermos receber um parâmetro, por exemplo, index($parametro), modificaríamos nosso arquivo de rota assim:

    tofu.index:
      path: '/index/{parametro}'
      defaults:
        _controller: '\Drupal\tofu\Controller\TofuController::index'
      requirements:
        _permission: 'access content'

    Carregando node automaticamente a partir do nid na rota

    O Drupal vai muito além. Suponha que esse $parametro, por algum motivo, seja o nid de nodes do seu site. Poderíamos, dentro do controller, carregar o node baseado nos id recebido, mas podemos fazer essa injeção diretamente no arquivo de rotas, assim, a variável $parametro será diretamente um objeto do tipo node:

    tofu.index:
      path: '/bla/{parametro}'
      defaults:
        _controller: '\Drupal\tofu\Controller\TofuController::index'
      requirements:
        _permission: 'access content'
      options:
        parameters:
          parametro:
            type: entity:node

    Controllers

    Exemplo básico de um controller:

    use Drupal\Core\Controller\ControllerBase;
    
    class ExemploController extends ControllerBase{
      public function index(){
        return [
          '#markup' => $this->t('Hello People')
        ];
      }
    }

    Services

    Criando e utilizando services

    Vamos criar a classe UteisService.php e veremos como utilizá-la no controller.

    ./vendor/bin/drupal generate:service  \
      --module="tofu"  \
      --name="tofu.uteis"  \
      --class="UteisService"  \
      --path-service="src" \
      --no-interaction

    Note que foi criada uma entrada em tofu.services.yml que define nossa classe como um serviço para o drupal.

    Na classe UteisService.php, como exemplo, vamos criar um método que dada uma string, a devolve invertida e com todas letras em maisculá:

    public function inverte($string){
      return strtoupper(strrev($string));
    }

    Injetando um serviço no controller

    Queremos usar no nosso controller o método inverte($string) que está em UteisService.php, mas carregado como serviço. Isso significa que ao chamarmos $this->tofuUteis->inverte(‘Maria’) recebemos como resposta AIRAM.

    Usando o mesmo comando do drupal console para criar a rota /tofu e o controller TofuControler podemos passar a flag services e especificar o serviço tofu.uteis:

    ./vendor/bin/drupal generate:controller  \
      --module="tofu"  \
      --class="TofuController"  \
      --routes='"title":"index", "name":"tofu.index", "method":"index", "path":"/tofu"'  \
      --services="tofu.uteis" \
      --no-interaction

    A saída será como abaixo, criando uma váriável $tofuUteis, objeto instanciado do nosso serviço.

      use Symfony\Component\DependencyInjection\ContainerInterface;
      ...
      protected $tofuUteis;
      public static function create(ContainerInterface $container) {
        $instance = parent::create($container);
        $instance->tofuUteis = $container->get('tofu.uteis');
        return $instance;
      }

    Eu costumo também fazer de outra maneira, não sei qual é a melhor forma de injetar o serviço no controller, mas ambas funcionam. Forma manual:

    1 - No controller, declarar ContainerInterface e a classe do serviço:

    use Symfony\Component\DependencyInjection\ContainerInterface;
    use Drupal\tofu\UteisService;

    2 - No __construct do controller receber a classe do serviço como paramêtro em atribuir numa variável local:

    protected $tofuUteis;
    public function __construct(UteisService $tofuUteis){
      $this->tofuUteis = $tofuUteis;
    }

    3 - Por fim, no método create(), que é chamado antes do controller, carregar o $container com o serviço:

    public static function create(ContainerInterface $container){
      return new static (
        $container->get('tofu.uteis')
      );
    }

    Sempre olhar o __contruct() e create() da classe mãe da qual esteja injetando o service, pois neste caso, você deve injetar os services que a classe mãe também injeta. Assim, supondo que sua classe mãe injete mais dois serviços, $a e $b, para injetar o nosso tofu.uteis faríamos assimo no controller:

    protected $tofuUteis;
    public function __construct(A $a, B $b, UteisService $tofuUteis){
      parent::__construct($a, $b);
      $this->tofuUteis = $tofuUteis;
    }

    E no método create retornamos todos serviços que já eram carregados, acrescentando o nosso:

    public static function create(ContainerInterface $container){
      return new static (
        $container->get('modulo1.a'),
        $container->get('modulo2.b'),
        $container->get('tofu.uteis')
      );
    }

    DAQUI PARA BAIXO FALTA REVISAR

    Injetando service config.factory em classes do seu sistema

    Suponha que sua classe src/Service/Uteis.php precise carregar configurações do site.

    ./vendor/bin/drupal generate:service  \
    --module="tofu"  \
    --name="tofu.uteis"  \
    --class="UteisService"  \
    --path-service="src/Service" \
    --services="config.factory" \
      --no-interaction

    Na declaração de tofu.services.yml:

    services:
      tofu.uteis:
        class: Drupal\tofu\Service\Uteis
        arguments: ['@config.factory']

    Em src/Service/Uteis.php declare ConfigFactoryInterface:

    use Drupal\Core\Config\ConfigFactoryInterface;

    E por fim, injete $config_factory no __construct:

    protected $config_factory;
    public function __construct(ConfigFactoryInterface $config_factory){
      $this->config_factory = $config_factory;
    }

    Agora é possível carregar configurações em qualquer métodos de Uteis.php assim:

    $this->config_factory->get('NOME_DA_CONFIG');

    Formulário de configuração do módulo

    A seguir estão os passos para criamos um formulário de configuração de um módulo, delegando para o sistema de configuração, o armazenamento dos dados.

    1 - Criando rota que aponta para ao classe do tipo Form:

    tofu.configuracoes:
      path: '/admin/config/tofu'
      defaults:
        _form: '\Drupal\tofu\Form\ConfiguracoesForm'
      requirements:
        _permission: 'administer site configuration'

    2 - Se quiser uma entrada na área de configurações do site para esse módulo, em tofu.links.menu.yml inserir seguinte conteúdo:

    tofu.configuracoes:
      title: 'Módulo Tofu'
      route_name: tofu.configuracoes
      description: 'Configurações do módulo tofu'
      parent: system.admin_config_system
      weight: 99

    3 - Criar a classe do formulário em src/Form estendendo ConfigFormBase, olhe cada método, eles são bem intuitivos. O formulário é construído no buildForm, veja uma lista de tipos de campos possíveis em https://api.drupal.org/api/drupal/elements. Em validateForm, adivinhe, validamos o formulário. Em submitForm salvamos, mas podemos processar os valores antes de salvar. E em getEditableConfigNames carregamos o serviço de configuração.

    namespace Drupal\tofu\Form;
    
    use Drupal\Core\Form\ConfigFormBase;
    use Drupal\Core\Form\FormStateInterface;
    
    class ConfiguracoesForm extends ConfigFormBase {
    
      public function getFormId() {
        return 'tofu_admin_settings';
      }
    
      protected function getEditableConfigNames() {
        return [
          'tofu.settings',
        ];
      }
    
      public function buildForm(array $form, FormStateInterface $form_state) {
        $config = $this->config('tofu.settings');
        $form['um_texto_qualquer'] = [
          '#type' => 'textfield',
          '#title' => $this->t('Digite um texto qualquer'),
          '#default_value' => $config->get('um_texto_qualquer'),
        ];
        return parent::buildForm($form, $form_state);
      }
    
      public function validateForm(array &$form, FormStateInterface $form_state) {
        $x = $form_state->getValue('um_texto_qualquer');
        if($x == 'José'){
          $form_state->setErrorByName('um_texto_qualquer',$this->t('José não vai...'));
        }
      }
    
      public function submitForm(array &$form, FormStateInterface $form_state) {
        $this->config('tofu.settings')
          ->set('um_texto_qualquer', $form_state->getValue('um_texto_qualquer'))
          ->save();
        parent::submitForm($form, $form_state);
      }
    
    }

    Não precisamos necessariamente apontar uma rota para o nosso formulário. Podemos redenderizar o formulário de dentro do controller injetando o serviço form_builder:

    ...
    use Symfony\Component\DependencyInjection\ContainerInterface;
    use Drupal\Core\Form\FormBuilder;
    ...
      protected $builder;
      public function __construct(Uteis $uteis, FormBuilder $builder){
        $this->uteis = $uteis;
        $this->builder = $builder;
      }
      public static function create(ContainerInterface $container){
        return new static(
          $container->get('form_builder')
        );
    
      ...
      // No seu método pode carregar o form:
      $form = $this->builder->getForm('Drupal\tofu\Form\ConfiguracoesForm');
      return $form;
      ...

    A vantagem nesse caso é que a variável $form é um render array que pode ser manipulado antes de ser retornado.

    alter ID form: exemplo modificando a página de informações do site

    Temos que saber o ID do formulário, aquele definido em getFormId(). Um caminho é identificar a rota do formulário:

    ./vendor/bin/drupal debug:router| grep site-information

    E sabendo-se a rota, podemos ver qual é a classe do formulário:

    /vendor/bin/drupal debug:router system.site_information_settings

    Encontramos assim que o formulário está em core/modules/system/src/Form/SiteInformationForm.php identificamos o id retornado no método getFormId(): system_site_information_settings.

    Em tofu.module podemos implementar o hook_form_ID_alter. No nosso exemplo, vamos: colocar um campo de texto a mais na página de configuração, validar e salvar:

    function tofu_form_system_site_information_settings_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id){
        $config = \Drupal::service('config.factory')->getEditable('system.site');
        $form['um_texto_qualquer'] = [
          '#type' => 'textfield',
          '#title' => 'Digite um texto qualquer',
          '#default_value' => $config->get('um_texto_qualquer'),
        ];
        /* Métodos para salvar e validar novo campo*/
        $form['#submit'][] = '_um_texto_qualquer_form_submit';
        $form['#validate'][] = '_um_texto_qualquer_form_validate';
    }
    
    function _um_texto_qualquer_form_submit(&$form, \Drupal\Core\Form\FormStateInterface $form_state){
        $config = \Drupal::service('config.factory')->getEditable('system.site');
        $config->set('um_texto_qualquer',$form_state->getValue('um_texto_qualquer'))->save();
    }
    
    function _um_texto_qualquer_form_validate(&$form, \Drupal\Core\Form\FormStateInterface $form_state){
    
        $x = $form_state->getValue('um_texto_qualquer');
        if($x == 'José'){
          $form_state->setErrorByName('um_texto_qualquer','José não, vai...');
        }
    }

    Plugin: Bloco customizado

    Vamos criar um plugin e esse plugin será um bloco customizado dentro de src/Plugin/Block.

    1 - Criar classe TofuBlock (src/Plugin/Block/TofuBlock.php) estendendo BlockBase. Basta criarmos uma annotation com o id e título do bloclo. O único método que precisamos é o build() que deve retornar um render array com o markup do texto que será mostrado no bloco.

    namespace Drupal\tofu\Plugin\Block;
    use Drupal\Core\Block\BlockBase;
    
    /**
     * @Block(
     *   id = "tofu_block",
     *   admin_label = @Translation("Bloco do Tofu"),
     * )
     */
    class TofuBlock extends BlockBase {
      public function build() {
        return [
          '#markup' => $this->t('Sou o bloco tofu'),
        ];
      }
    }

    Mas e se queremos manipular configurações dentro do nosso bloco? Neste caso, ao invés de injetar o config.factory, vamos implementar uma interface. Se for apenas configuração que precisamos injetar o mais fácil é implementar BlockPluginInterface, e usar a configuração relacionada ao bloco com $this->getConfiguration(). Vamos aproveitar e implementar o método blockForm para mostrar um formulário na configuração do bloco, com apenas um campo, e blockSubmit para salvar a configuração e blockValidate para validar os campos.

    namespace Drupal\tofu\Plugin\Block;
    
    use Drupal\Core\Block\BlockBase;
    use Drupal\Core\Block\BlockPluginInterface;
    use Drupal\Core\Form\FormStateInterface;
    
    /**
     * @Block(
     *   id = "tofu_block",
     *   admin_label = @Translation("Bloco do Tofu"),
     * )
     */
    class TofuBlock extends BlockBase implements BlockPluginInterface {
    
      public function build() {
        $config = $this->getConfiguration();
        $nome = isset($config['nome']) ? $config['nome'] : 'sem nome...';
        return [
          '#markup' => $this->t('Sou o bloco tofu. Meu nome é: @nome',[
            '@nome' => $nome
          ]),
        ];  
      }
      public function blockForm($form, FormStateInterface $form_state) {
        $form = parent::blockForm($form, $form_state);
        $config = $this->getConfiguration();
    
        $form['nome'] = array(
          '#type' => 'textfield',
          '#title' => t('Nome'),
          '#default_value' => isset($config['nome']) ? $config['nome'] : 'Sem nome...',
        );
        return $form;
      }
    
      public function blockSubmit($form, FormStateInterface $form_state) {
        $this->setConfigurationValue('nome', $form_state->getValue('nome'));
      }
    
      public function blockValidate($form, FormStateInterface $form_state) {
        $nome = $form_state->getValue('nome');
    
        if ($nome != 'Tofu') {
          $form_state->setErrorByName('nome', t('Esse nome não é bonito!'));
        }
      }
    }

    Injetando services em Plugins

    Tudo muito bonito. E se precisarmos injetar outro serviço que não a configuração? Por exemplo, o tofu.uteis? Neste caso devemos implementar ContainerFactoryPluginInterface, o que nos obriga a declarar __construct e create(), levemente diferente dos que que já vimos até agora, pois estamos no contexto de plugins, onde temos que passar o id e plugin definition no create e no __construct. O interessante é que ganhamos de graça a configuração, pois ainda temos acesso $this->setConfigurationValue('nome','valor') e $this->getConfiguration().

    Assim, particularmente, eu prefiro implementar ContainerFactoryPluginInterface do que BlockPluginInterface, pois fica genérico para qualquer plugin.

    namespace Drupal\tofu\Plugin\Block;
    
    use Drupal\Core\Block\BlockBase;
    use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
    use Symfony\Component\DependencyInjection\ContainerInterface;
    use Drupal\Core\Form\FormStateInterface;
    use Drupal\tofu\Service\Uteis;
    /**
     * @Block(
     *   id = "tofu_block",
     *   admin_label = @Translation("Bloco do Tofu"),
     * )
     */
    class TofuBlock extends BlockBase implements ContainerFactoryPluginInterface {
    
      protected $uteis;
      public function __construct(array $configuration, 
        $plugin_id, $plugin_definition, Uteis $uteis){
        parent::__construct($configuration, $plugin_id, $plugin_definition);
        $this->uteis = $uteis;
      }
    
      public static function create(ContainerInterface $container, 
        array $configuration, $plugin_id, $plugin_definition){
        return new static (
          $configuration,
          $plugin_id, 
          $plugin_definition,
          $container->get('tofu.uteis')
        );
      }
    
      public function build() { 
        $config = $this->getConfiguration();
        $nome = isset($config['nome']) ? $config['nome'] : 'sem nome...';
        return [
          '#markup' => $this->t($this->uteis->inverte($nome)),
        ];
      }
    
      public function blockForm($form, FormStateInterface $form_state) {
        $form = parent::blockForm($form, $form_state);
        $config = $this->getConfiguration();
    
        $form['nome'] = array(
          '#type' => 'textfield',
          '#title' => t('Nome'),
          '#default_value' => isset($config['nome']) ? $config['nome'] : 'Sem nome...',
        );
        return $form;
      }
    
      public function blockSubmit($form, FormStateInterface $form_state) {
        $this->setConfigurationValue('nome', $form_state->getValue('nome'));
      }
    
      public function blockValidate($form, FormStateInterface $form_state) {
        $nome = $form_state->getValue('nome');
    
        if ($nome != 'Tofu1') {
          $form_state->setErrorByName('nome', t('Esse nome não é bonito!'));
        }
      }
    }

    phpunit

    Para usar o phpunit no contexto do módulo eu tive que inserir na minha instalação do drupal de desenvolvimento as seguintes linhas no composer.json (pode ser na seção dev):

    "phpunit/phpunit": "^7",
    "symfony/phpunit-bridge": "^5.1",
    "behat/mink-goutte-driver": "^1.0",
    "drupal/group": "^1.0"

    Depois, copie o arquivo phpunit.xml de modelo:

    cp web/core/phpunit.xml.dist web/core/phpunit.xml
    mkdir -p /home/thiago/drupal-dev/web/sites/simpletest/browser_output

    E configure as variáveis dentro de phpunit.xml:

    • SIMPLETEST_BASE_URL: http://127.0.0.1:8088/
    • SIMPLETEST_DB: sqlite://localhost//home/thiago/repos/drupal-dev/web/sites/default/files/.ht.sqlite
    • BROWSERTEST_OUTPUT_DIRECTORY: /home/thiago/drupal-dev/web/sites/simpletest/browser_output

    Exemplo de rodada dos testes funcionais em um módulo contrib, no caso, webform:

    ./vendor/bin/phpunit -c web/core --testsuite=functional web/modules/contrib/webform

    Ligando flags de debug:

    ./vendor/bin/phpunit -c web/core --debug --verbose --testsuite=functional web/modules/contrib/webform

    Também é possível apontar para um arquivo em específico:

    ./vendor/bin/phpunit -c web/core --testsuite=functional web/modules/contrib/webform/tests/src/Functional/WebformResultsExportDownloadTest.php

    Dicas para configurar seu ambiente

    TODO: passos da instalação do phpcs Alias para colocar no seu bashrc:

    alias drupalcs="phpcs --standard=Drupal --extensions='php,module,inc,install,test,profile,theme,css,info,txt,md'" 
    alias drupalcsp="phpcs --standard=DrupalPractice --extensions='php,module,inc,install,test,profile,theme,css,info,txt,md'" 
    alias drupalcbf="phpcbf --standard=Drupal --extensions='php,module,inc,install,test,profile,theme,css,info,txt,md'"

    Usando pareviewsh localmente (mesmo efeito de usar pelo site https://pareview.sh/):

    mkdir ~/temp
    cd temp
    git clone https://git.drupalcode.org/project/pareviewsh
    cd pareviewsh
    composer install

    Referências

    • https://drupalbook.org/