Step 19: Making Decisions with a Workflow

Making Decisions with a Workflow

We might want to let the website admin moderate all comments after the spam checker. The process would be something along the lines of:

  • Start with a state when a comment is submitted by a user;
  • Let the spam checker analyze the comment and switch the state to either potential_spam, ham, or rejected;
  • If not rejected, wait for the website admin to decide if the comment is good enough by switching the state to published or rejected.

Implementing this logic is not too complex, but you can imagine that adding more rules would greatly increase the complexity. Instead of coding the logic ourselves, we can use the Symfony Workflow Component:

The comment workflow can be described in the config/packages/workflow.yaml file:

config/packages/workflow.yaml

  1. framework:
  2. workflows:
  3. comment:
  4. type: state_machine
  5. audit_trail:
  6. enabled: "%kernel.debug%"
  7. marking_store:
  8. type: 'method'
  9. property: 'state'
  10. supports:
  11. - App\Entity\Comment
  12. initial_marking: submitted
  13. places:
  14. - submitted
  15. - ham
  16. - potential_spam
  17. - spam
  18. - rejected
  19. - published
  20. transitions:
  21. accept:
  22. from: submitted
  23. to: ham
  24. might_be_spam:
  25. from: submitted
  26. to: potential_spam
  27. reject_spam:
  28. from: submitted
  29. to: spam
  30. publish:
  31. from: potential_spam
  32. to: published
  33. from: potential_spam
  34. to: rejected
  35. publish_ham:
  36. from: ham
  37. to: published
  38. reject_ham:
  39. to: rejected

To validate the workflow, generate a visual representation:

Note

Replace the current logic in the message handler with the workflow:

patch_file

  1. --- a/src/MessageHandler/CommentMessageHandler.php
  2. +++ b/src/MessageHandler/CommentMessageHandler.php
  3. @@ -6,19 +6,28 @@ use App\Message\CommentMessage;
  4. use App\Repository\CommentRepository;
  5. use App\SpamChecker;
  6. use Doctrine\ORM\EntityManagerInterface;
  7. +use Psr\Log\LoggerInterface;
  8. use Symfony\Component\Messenger\Handler\MessageHandlerInterface;
  9. +use Symfony\Component\Messenger\MessageBusInterface;
  10. +use Symfony\Component\Workflow\WorkflowInterface;
  11. class CommentMessageHandler implements MessageHandlerInterface
  12. {
  13. private $spamChecker;
  14. private $entityManager;
  15. private $commentRepository;
  16. + private $bus;
  17. + private $workflow;
  18. + private $logger;
  19. - public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository)
  20. + public function __construct(EntityManagerInterface $entityManager, SpamChecker $spamChecker, CommentRepository $commentRepository, MessageBusInterface $bus, WorkflowInterface $commentStateMachine, LoggerInterface $logger = null)
  21. {
  22. $this->entityManager = $entityManager;
  23. $this->spamChecker = $spamChecker;
  24. $this->commentRepository = $commentRepository;
  25. + $this->bus = $bus;
  26. + $this->workflow = $commentStateMachine;
  27. + $this->logger = $logger;
  28. }
  29. public function __invoke(CommentMessage $message)
  30. @@ -28,12 +37,21 @@ class CommentMessageHandler implements MessageHandlerInterface
  31. return;
  32. }
  33. - if (2 === $this->spamChecker->getSpamScore($comment, $message->getContext())) {
  34. - $comment->setState('spam');
  35. - } else {
  36. - $comment->setState('published');
  37. - }
  38. - $this->entityManager->flush();
  39. + $score = $this->spamChecker->getSpamScore($comment, $message->getContext());
  40. + $transition = 'accept';
  41. + if (2 === $score) {
  42. + $transition = 'reject_spam';
  43. + } elseif (1 === $score) {
  44. + $transition = 'might_be_spam';
  45. + }
  46. + $this->workflow->apply($comment, $transition);
  47. + $this->entityManager->flush();
  48. +
  49. + $this->bus->dispatch($message);
  50. + } elseif ($this->logger) {
  51. + $this->logger->debug('Dropping comment message', ['comment' => $comment->getId(), 'state' => $comment->getState()]);
  52. + }
  53. }
  54. }

The new logic reads as follows:

  • If the accept transition is available for the comment in the message, check for spam;
  • Depending on the outcome, choose the right transition to apply;
  • Call apply() to update the Comment via a call to the setState() method;
  • Call flush() to commit the changes to the database;
  • Re-dispatch the message to allow the workflow to transition again.

As we haven’t implemented the admin validation, the next time the message is consumed, the “Dropping comment message” will be logged.

Let’s implement an auto-validation until the next chapter:

patch_file

Run symfony server:log and add a comment in the frontend to see all transitions happening one after the other.

We have just come across such an example with the injection of a WorkflowInterface in the previous section.

As we inject any instance of the generic WorkflowInterface interface in the contructor, how can Symfony guess which workflow implementation to use? Symfony uses a convention based one the argument name: $commentStateMachine refers to the comment workflow in the configuration (which type is state_machine). Try any other argument name and it will fail.

If you don’t remember the convention, use the debug:container command. Search for all services containing “workflow”:

  1. $ symfony console debug:container workflow
  2. Select one of the following services to display its information:
  3. [0] console.command.workflow_dump
  4. [1] workflow.abstract
  5. [2] workflow.marking_store.method
  6. [3] workflow.registry
  7. [4] workflow.security.expression_language
  8. [5] workflow.twig_extension
  9. [6] monolog.logger.workflow
  10. [7] Symfony\Component\Workflow\Registry
  11. [8] Symfony\Component\Workflow\WorkflowInterface $commentStateMachine
  12. [9] Psr\Log\LoggerInterface $workflowLogger
  13. >

Notice choice 8, Symfony\Component\Workflow\WorkflowInterface $commentStateMachine which tells you that using $commentStateMachine as an argument name has a special meaning.

Note

We could have used the debug:autowiring command as seen in a previous chapter:

Going Further

  • The .