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.


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*

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


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'

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'

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

Exemplo básico de um controller:

use Drupal\Core\Controller\ControllerBase;

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


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));
}

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, aeb, 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')
  );
}


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');

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.


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...');
    }
}

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!'));
    }
  }
}

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!'));
    }
  }
}

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

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

  • https://drupalbook.org/