The problem to solve:
Imagine you run a community/social website, where users can comment on posts/comments' threads, but wanted to unpublish some comments based on some rules or company TCs. Maybe some slurs or anything around that. Now you could achieve this without code by configuring modules like rules and views, but our blog title has the word creative and Drupal apis in it: they also say Drupal is a framework and our title is not a clickbait; if you can count beyond ten, those are four reasons why we should solve this problem via custom code.
We have four!! |
The plan:
Implement hook_entity_insert() to listen to event of type 'entity insert' of entity type id 'comment', and register a service/class "Pattern" to be used for the logic inside this hook.
Create a controller/route to display these 'unpublished' comments, to be reviewed by the right user (roles/permissions).
Create a custom twig filter/function (creative_format_date) to be used together with our template in the controller above.
Implement custom Drush commands to better manage this feature and improve the developer experience.
Prerequisites:
All that is required is a working instance of a Drupal project in your local machine, with at least one user (uid: 1 and two article content/dummy for use by the Drush generate spam comments command we will add later).
'creative' will be the name of this module and we will follow the file(s) naming conventions.
Folder structure:( place the creative folder inside the modules/custom folder )
The module repo is available on Github and I invite you to use that also as a reference through out this blog, just in case.
High level purpose of each file:
- creative.info.yml (required); Contains our module details including name, description, supported core versions, type (module) etc and makes our module discoverable/aware by Drupal to install.
- creative.libraries.yml: Defines our libraries (stylesheets and javascript) that we will use to style the page(s) our controller defines.
- creative.module: Defines/contains our custom hooks (in our case just hook_entity_insert() and hook_theme())
- creative.routing.yml: Defines our routes path and the attributes around that, including the controller and method for each defined path (src/Controller folder).
- creative.services.yml: Defines our custom services to be added to the Drupal service container, so other parts of Drupal can access their instances via the service container.
- drush.services.yml: Defines the class that handles our custom drush commands as well us makes our drush commands discoverable by Drupal (src/Commands).
Contents of files and how they are wired:
In this section, we will go through the contents of each file and how they are wired/discovered around Drupal.
We will start at the bottom of our feature (src\Service) folder and work our way up, to where we interact with the drupal apis.
#src/Service and src/Twig folder:
src/Service and src/Twig folder:
Pattern file in the src/Service folder and CreativeTwigExtension in the src/Twig folder relies on creative.service.yml which registers the services (Pattern and CreativeTwigExtension ) to the Drupal app service container.
In creative.service.yml file we are registering Pattern service with the class namespace and for the CreativeTwigExtension, we are using a service id(machine name) whose value is the CreativeTwigExtension namespace. I choose to use two different ways to register the service, just so any looks familiar.
It's also required to add a key of id tags with a array of an object with property name of value 'twig.extension' (to the custom twig filter/function) so it's added and discovered by the twig templating engine app instance.
The Pattern class provides a method named doesMatch($input), which takes a string as a param and compares that against a constant PATTERN (a regex expression) of the same class and returns an array of matching values if any. We will use this later in one of our module hooks to check against comment subject and comment body values.
The CreativeTwigExtension defines a custom twig filter creative_format_date which references a callback function timeElapsedString($datetime), which takes in a timestamp and returns a string of how much time has elapsed since. We will use this the the twig template file (templates/creative-spam-comments.html.twig)
We will now see the code in the above 3 files before moving on to the .modules, .routing/Controller and the template file.
// start of creative.services.yml
services:
Drupal\creative\Service\Pattern:
class: Drupal\creative\Service\Pattern
arguments: []
creative.twig:
class: Drupal\creative\Twig\CreativeTwigExtension
tags:
- { name: twig.extension }
// end of creative.services.yml
// Start of src\Service\Pattern.php file
<?php
namespace Drupal\creative\Service;
/**
* Provides regex function(s)
*/
class Pattern {
const PATTERN = '/(wolf|woof|moon)/i';
/**
* Returns an array of text that match
* the pattern above.
*
* @param $input
*
* @return array
*/
public function doesMatch($input): array {
// $matches is passed by reference
preg_match_all(self::PATTERN, $input, $matches);
return $matches;
}
}
// End of src\Service\Pattern.php file
// Start of src\Twig\CreativeTwigExtension.php file
<?php
namespace Drupal\creative\Twig;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
/**
* Class CreativeTwigExtension provides twig filter functions.
*/
class CreativeTwigExtension extends AbstractExtension {
/**
* @return \Twig\TwigFilter[]
*/
public function getFilters() {
return [
new TwigFilter('creative_format_date', [$this, 'renderFormatDate']),
];
}
/**
* @param $date
* @return string
*/
public function renderFormatDate($date) {
return $this->timeElapsedString($date);
}
/**
* @param $datetime
* @return string
*/
private function timeElapsedString($datetime) {
/** @var \Drupal\Core\Datetime\DateFormatterInterface $formatter */
$formatter = \Drupal::service('date.formatter');
$now = \Drupal::time()->getRequestTime();
/** @var \Drupal\Core\Datetime\FormattedDateDiff $diff */
$diff = $formatter->formatDiff($datetime, $now, [
'granularity' => 7,
'return_as_object' => TRUE,
])->getString();
return ' '.$diff . ' ago.';
}
}
// End of src\Twig\CreativeTwigExtension.php file
# .modules, .routing, Controller\SpamController.php and the template files.
The creative.module file, contains two functions/hooks:
hook_entity_insert(): Which is fired when entity type of id 'comment' has been stored, and uses the Pattern service (class) to check if the comment subject or body contains any of the words wolf, woof or moon and returns an array of the matching words, we then use this logic to determine if we should revert, the just stored comment from published to unpublished by setting its property 'status' from '1' to a '0'.
andhook_theme(): Implements the logic behind how we will render the unpublished comments in our controller; defining the theme id, template to use and variables to expect.
The creative.routing.yml file makes our route/path ('creative/spam-comments') discoverable by Drupal, and defines some other requirements, like the Controller class and class method that servers each defined path(s), the permissions and such.
The src/Controller/SpamController.php file defines the Class and method that handles our path, as defined in the creative.routing.yml, the Controller method getComments returns a render array, including the template to use (defined in our hook_theme()), the items as an array of Comment objects (limit 30 items) and the pager variables as a render element. The $render_array variable also includes an #attached render element of type library which references the definitions (attr and all) of the bootstrap libraries we're loading from cdn @creative.libraries.yml file, (the javascript functionality is not needed anywhere in our page, but we are requiring just for this demo).
Below is the code in each for these files:
// start of creative.module file
<?php
/**
* @file
* Contains hook implements for the creative module.
*/
use Drupal\Core\Entity\EntityInterface;
use Drupal\creative\Service\Pattern;
/**
* Implements hook_entity_insert().
*/
function creative_entity_insert(EntityInterface $entity): void {
if ($entity->getEntityTypeId() == 'comment') {
/** @var \Drupal\creative\Service\Pattern $pattern */
$pattern = Drupal::getContainer()->get(Pattern::class);
$comment_subject = $entity->getSubject();
$comment_body = $entity->get('comment_body')->value;
if ($pattern->doesMatch($comment_subject)[0] || $pattern->doesMatch($comment_body)[0]) {
$entity->set('status', 0);
$entity->save();
}
}
}
/**
* Implements hook_theme().
*/
function creative_theme($existing, $type, $theme, $path): array {
return [
'creative_spam_comments' => [
'render-element' => 'children',
// Template == creative-spam-comments.html.twig.
'template' => 'creative-spam-comments',
'variables' => [
'items' => NULL,
'pager' => NULL,
],
],
];
}
// end of creative.module file
// start of creative.routing.yml file
creative.spam_comments:
path: 'creative/spam-comments'
defaults:
_controller: '\Drupal\creative\Controller\SpamController::getComments'
_title: 'Spam comments'
requirements:
_permission: 'administer content'
// end of creative.routing.yml file
// start of src\Controller\SpamController.php file
<?php
namespace Drupal\creative\Controller;
use Drupal\comment\Entity\Comment;
use Drupal\Core\Controller\ControllerBase;
/**
* Provides the spam comments listing page.
*/
class SpamController extends ControllerBase {
/**
* Returns a render-able array for spam comments page.
*/
public function getComments(): array {
$query = \Drupal::entityQuery('comment');
$ids = $query
->condition('status', 0)
->pager(30)
->execute();
/** @var \Drupal\comment\Entity\Comment[] $comments */
$comments = Comment::loadMultiple($ids);
$render_array = [
// Your theme hook name defined in creative.module file.
'#theme' => 'creative_spam_comments',
'#attached' => [
'library' => 'creative/creative_bootstrap',
],
// The items twig variable as an array as defined in creative hook_theme().
'#items' => $comments,
// The pager twig variable as a render array as defined in creative hook_theme()
'#pager' => [
'#type' => 'pager',
],
'#cache' => [
'max-age' => 0,
],
];
return $render_array;
}
}
// End of src\Controller\SpamController.php file
Upto this point, we can test the feature by enabling the module and adding comments to any existing node (content), that include any of the words/phrases defined in the Pattern class/service and that will propagate through the hook_entity_insert() function/listener, causing the comments with such words to be unpublished right after creation.
In the templates/creative-spam-comments.html.twig file, all that markup implementation is rendered in the content region.
We have access to the Comment object as each 'items' variable passed from our controller, so we can access the public methods/properties for the same.
Also the created date is formatted by the custom twig filter 'creative_format_date' registered in the 'creative.services.yml' file with tag 'twig.extension'.
#Demystifying the pager render element and \Drupal::entityQuery('<entity type id>');
:
I will be honest, I thought I needed to do more with the pager render element and the ?page= in the uri as you walk/click through the pager links, but: every time a user clicks on a page id (pager item), it makes a get request to the same page adding a request param of id 'page' with a value of the clicked item from the pager(page number), for example; if a user clicks on item 3, that instructs Drupal to make a get request, on the current page, adding '?page=3' on the current page, the \Drupal::entityQuery('<entity type id>');
class return a '\Drupal\Core\Entity\Query\QueryInterface or rather Query/QueryBase.php ' instance which in turn extends Drupal\Core\Database\Query\PagerSelectExtender; ; it has access to the request object and uses the page query param to determine what items to load (pager offset).
// start of templates/creative-spam-comments.html.twig
<!-- Comments list -->
<div class="container">
<div class="row d-flex justify-content-center">
<div class="col">
<div class="card shadow-0 border" style="background-color: #f0f2f5;">
<div class="card-body p-4">
{% for comment in items %}
<div class="card mb-4" key="{{ comment.id }}">
<div class="card-body">
<h5 class="card-title">{{ comment.get('subject').value }}</h5>
<div class="card-text">
<p>{{ comment.get('comment_body').value }}</p>
<div class="d-flex justify-content-between">
<div class="d-flex flex-row align-items-center">
<img src="https://via.placeholder.com/150/000000/FFFFFF/?text=IPaddress.netC/O https://placeholder.com/ " alt="avatar" width="25"
height="25" />
<p class="small mb-0 ms-2">{{ comment.getAuthorName }}</p>
</div>
<div class="d-flex flex-row align-items-center">
<p class="small text-muted m-1">Created: </p>
<p class="small text-muted m-1"> {{ comment.get('created').value | creative_format_date }}</p>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{{ pager }}
</div>
// end of templates/creative-spam-comments.html.twig
// start of creative.libraries.yml
creative_bootstrap:
version: VERSION
css:
theme:
//cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/css/bootstrap.min.css: { external: true }
js:
//cdn.jsdelivr.net/npm/bootstrap@5.2.0-beta1/dist/js/bootstrap.bundle.min.js: { external: true }
// end of creative.libraries.yml
Finally the Drush commands to make developing and maintenance better as well as a custom Batch Operation to enhance this on 'production!!'.
The last part of this blog is about the remaining three files, i.e;
drush.services.yml, src\Service\BatchService.php and src\Commands\CreativeCommands.php.
The drush.services.yml makes our CreativeCommands.php command(s) definitions discoverable by Drush and includes the commands machine name ('creative.commands') with defaults such as the php class and a tag of 'drush.command' which is required. It also contains a key of 'calls' which is an array of our constructor methods to call in the class defined above.
Required to install are Drush and the faker php library by running composer require drush/drush fakerphp/faker
because the CreativeCommands class depends on them, it's also a common practice to install drush globally and also as a project dependency. (NB: if you install drush only in your project scope use vendor/bin/drush <command id>
instead)
The CreativeCommand class defines two drush commands, one to create spam comments (drush creative:comment:generate 20) and another to delete all comments that are unpublished (drush creative:comments:delete). Generate comments is meant to be used in development, where we generate spam comments and test the hook_entity_create() definitions on creative.module file and creative:comments:delete
deletes the spam comments after they have been reviewed by the user(s) with such roles or permissions.
The creative:comment:generate <comments count>
command uses faker generator instance to generate the comment subject and body and also includes one item from the const SLURS of the CreativeCommands class, to make sure the hook_entity_insert() is fired/called on each comment generated. It also assigns the uid (comment owner) to value 1 and entity_id (the node that the comment(s) belong to) to 2, the node id, so make sure you have a user and a node with the same ids before running this command.
The creative:comments:delete
utilizes the batch api because it's meant to be used in production (refer to src\Service\BatchService.php) which could also be called from a form submission handler on a normal Drupal page. I didn't include this in the services.yml because I felt that the Drupal service container doesn't need to be made aware of it, as we will be using it the drush commands for this example(we have access to it like an ordinally php namespaced class), About the batch api, I found this yt video pretty informative and with great use case examples.
// Start of drush.services.yml
services:
creative.commands:
class: \Drupal\creative\Commands\CreativeCommands
tags:
- { name: drush.command }
arguments: []
calls:
- [setFaker, []]
// End of creative.services.yml
// Start of src\Commands\CreativeCommands.php
<?php
namespace Drupal\creative\Commands;
use Drupal\Core\Batch\BatchBuilder;
use Drupal\creative\Service\BatchService;
use Drush\Commands\DrushCommands;
use Drush\Exceptions\UserAbortException;
use Faker\Factory;
/**
* Provides/defines custom commands associated with creative module.
*/
class CreativeCommands extends DrushCommands {
const SLURS = ['wolf', 'werewolf', 'moon', 'full-moon'];
/**
* Faker Generator instance.
*
* @var \Faker\Generator
*/
protected $faker;
/**
* Constructor setter for $faker property.
*
* @return void
*/
public function setFaker() : void {
$this->faker = Factory::create();
}
/**
* Drush command that generates dummy spam comments.
*
* @param int $howMany
* How many spam comments to generate
* argument provided to the drush command.
*
* @command creative:comments:generate
* @aliases cr-co-gn
*
* @usage creative:comment:generate 20
* 20 is the number of comments to generate
* || drush cr-co-gn 300 -y
* Where 300 is the number of comments to generate
* and -y flags all confirmations ($this->io()->confirm()).
*/
public function generateSpamComments(int $howMany) {
$confirm = $this->io()->confirm('Confirm you want to generate ' . $howMany . ' comments!');
if (!$confirm) {
throw new UserAbortException('Try again with the number of comments you wish to generate!');
}
$commentEntity = \Drupal::entityTypeManager()
->getStorage('comment');
for ($i = 0; $i < $howMany; $i++) {
$key = array_rand(self::SLURS);
$subject = $this->faker->words(3, TRUE) . self::SLURS[$key] . $this->faker->words(2, TRUE);
$body = $this->faker->paragraph(2) . self::SLURS[$key] . $this->faker->sentence(7);
$values = [
// Default with install.
'comment_type' => 'comment',
// Published or unpublished.
'status' => 1,
// Node id the comment belongs to.
'entity_id' => 2,
'subject' => $subject,
// Id of the comment owner/author.
'uid' => 1,
'comment_body' => $body,
'entity_type' => 'node',
// Field id on this node form.
'field_name' => 'comment',
];
/** @var \Drupal\comment\Entity\Comment $comment */
$comment = $commentEntity->create($values);
$comment->save();
$this->output()->writeln('Comment created: id = ' . $comment->id() . ' || Comment subject: ' . $comment->getSubject());
}
}
/**
* Drush command that deletes all spam comments.
*
* @command creative:comments:delete
*
* @aliases cr-co-del
*
* @usage creative:comments:delete
*/
public function deleteSpamCommentsEntity() {
$query = \Drupal::entityQuery('comment');
// Get all unpublished comments,
// status == 0 (unpublished)
$commentIds = $query
->condition('status', 0)
->execute();
$items = array_values($commentIds);
$this->logger()->notice(t('Initializing deletion of @count comments', [
'@count' => count($items),
]));
// Called on each op
$operation_callback = [
BatchService::class,
'processSpamComments'
];
$finish_callback = [
BatchService::class,
'processSpamCommentsFinished'
];
// Batch operation setup/definition instance.
$batch_builder = (new BatchBuilder())
->setTitle(t('Delete Spam comments batch process'))
->setFinishCallback($finish_callback)
->setInitMessage(t('Deleting Spam comments initialized'))
->setProgressMessage(t('Running delete Spam comments batch process'))
->setErrorMessage(t('Deleting Spam comments has encountered an error!'));
// Add as many ops as you would like, Each op goes through
// the progress bar from start to finish, then goes on to the next batch.
$batch_builder->addOperation($operation_callback, [$items]);
// If we are not inside a form submit handler we also need to call
// batch_process() to initiate the redirect if needed.
batch_set($batch_builder->toArray());
drush_backend_batch_process();
// Log message when done.
$this->logger()->notice("Batch operations finished.");
}
}
// End of src\Commands\CreativeCommands.php
// Start of src\Service\BatchService.php
<?php
namespace Drupal\creative\Service;
use Drupal\comment\Entity\Comment;
/**
* Class BatchService provides batch process callbacks and op(s).
* Documentation
* @ https://api.drupal.org/api/drupal/core%21includes%21form.inc/group/batch/8.8.x
* Also found this video with great examples/explanations on batch api
* @ https://www.youtube.com/watch?v=Xw90GKoc6Kc
*/
class BatchService
{
/**
* @param array $items
* @param array|\DrushBatchContext $context
* @return void
*/
public static function processSpamComments($items, &$context)
{
// Context sandbox is empty on initial load. Here we take care of things
// that need to be done only once. This context is then subsequently
// available for every subsequent batch run.
if (empty($context['sandbox'])) {
$context['sandbox']['progress'] = 0;
//$context['sandbox']['errors'] = [];
$context['sandbox']['max'] = count($items);
}
// If we have nothing to process, mark the batch as 100% complete (0 = not started
// , eg 0.5 = 50% completed, 1 = 100% completed).
if (!$context['sandbox']['max']) {
$context['finished'] = 1;
return;
}
// If we haven't yet processed all
if ($context['sandbox']['progress'] < $context['sandbox']['max']) {
// This is a counter that's passed from batch run to batch run.
if (isset($items[$context['sandbox']['progress']])) {
$comment = Comment::load($items[$context['sandbox']['progress']]);
// Let the editor know info about what is being run.
// If via drush command, also let user know of the
// progress percentage as they will not see the progress bar.
if (PHP_SAPI === 'cli') {
$context['message'] = t('[@percentage] Deleting comment subject: "@subject" and id: @id', [
'@percentage' => round(($context['sandbox']['progress'] / $context['sandbox']['max']) * 100) . '%',
'@subject' => $comment->getSubject(),
'@id' => $comment->id(),
]);
} else {
$context['message'] = t('Deleting "@subject" @id', [
'@subject' => $comment->getSubject(),
'@id' => $comment->id(),
]);
}
// Delete/remove the comment entry.
$comment?->delete();
}
$context['sandbox']['progress']++;
}
$context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
}
/**
* @param $success
* @param array $results
* @param array $operations
* @return void
*/
public static function processSpamCommentsFinished($success, array $results, array $operations): void
{
$messenger = \Drupal::messenger();
if ($success) {
$messenger->addMessage(t('Spam comments processed successfully.'));
} else {
// An error occurred.
// $operations contains the operations that remained unprocessed.
$error_operation = reset($operations);
$messenger->addMessage(
t('An error occurred while processing @operation with arguments : @args',
[
'@operation' => $error_operation[0],
'@args' => print_r($error_operation[0], TRUE),
]
)
);
}
}
}
// End of src\Service\BatchService.php
Conclusion:
We have touched some basics on some Drupal apis, but there more to each and even more apis that we haven't touched, that make Drupal great (as a framework), both for no code and code development.
Our feature is also not upto the basic standards as it lacks tests, which I will be sharing about in an upcoming blog (where we test the functional parts of our feature's implementations).
Resources:
Top comments (0)