Two Ways To Make Life Easier For You, The Developer


Mostly we concern ourselves with how end users will interact with our systems. On a day to day basis, however, it's us as developers that must work at a very much more intricate level with the code that makes the system possible. In this video we are going to address two problems which will directly impact you as a developer when working with Symfony's Workflow Component. These are:

  1. Using strings instead of constants;
  2. A lack of Logging by default

We're going to get started with addressing the issue or strings vs constants.

As of Symfony 3.2, we have been able to use PHP constants in YAML files.

This is fairly awesome, as it directly solves a problem we are very much likely to experience when working with the Workflow Component.

Let's take a look at our config so far:

// /app/config/workflows.yml

framework:
    workflows:
        customer_signup:
            supports: AppBundle\Entity\Customer
            initial_place: prospect
            places:
                - prospect
                - free_customer
                - paying_customer
            transitions:
                sign_up:
                    from: prospect
                    to: free_customer
                vip_approval:
                    from: free_customer
                    to: paying_customer

We are two transitions in, and most prominently, the string of free_customer has been used three times.

At this point in any PHP class you would very much likely be wanting to extract this information to a constant:

<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * Customer
 *
 * @ORM\Table(name="customer")
 * @ORM\Entity(repositoryClass="AppBundle\Entity\Repository\CustomerRepository")
 */
class Customer implements UserInterface, \Serializable
{
    public const FREE_CUSTOMER = 'free_customer';

Note here the use of public const rather than just const - this is Class Constant Visibility in action, a new addition in PHP 7.1. You can, of course, just use const FREE_CUSTOMER in older versions of PHP, and also in 7.1... but that's not really the point of discussion here :)

Ok, so we have defined a constant. How can we use it?

Well, the syntax is a little funky in my opinion, but it does work as intended:

// /app/config/workflows.yml

framework:
    workflows:
        customer_signup:
            supports: AppBundle\Entity\Customer
            initial_place: prospect
            places:
                - prospect
                - !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
                - paying_customer
            transitions:
                sign_up:
                    from: prospect
                    to: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
                vip_approval:
                    from: !php/const:AppBundle\Entity\Customer::FREE_CUSTOMER
                    to: paying_customer

Yeah, hmmm, not pretty.

Still, I am willing to sacrifice a little prettiness for the sake of reducing my curse-words-per-minute as my workflows grow in complexity.

The point being that aside from reducing the duplication of raw strings in our YAML files, we will be using the string of free_customer in many of the other places we need to interact with our workflows. This can be in controllers, services, and twig templates.

Using PHP constants in controllers / services / plain old PHP files is a common use case:

if ($this->get('workflow.customer_signup')->getMarking($customer)->has(Customer::FREE_CUSTOMER)) {
  // do something
}

But what about in a Twig template? Can we use constants there?

Yes, we can.

The downside? A different syntax again.

{% if workflow_has_marked_place(customer, constant('AppBundle\\Entity\\Customer::FREE_CUSTOMER')) %}
    <!-- do something -->
{% endif %}

Still, it's fairly straightforward to work with constants whether in PHP, YAML, or Twig templates. I would strongly advise it, even if it is more verbose.

Basic Logging In The Symfony Workflow Component

By default we don't get anything in the log files when working with the Symfony Workflow Component.

As we will see a little later in this series, this is somewhat unusual as any transition in the Workflow Component dispatches a ton of events. In a future video we will create our own logger, capturing lots of extra data, and covering why a purpose built logger might be the right option for you.

For now, we will make use of the provided logger, which is the AuditTrailListener.

To enabled the AuditTrailListener we need to add in an extra bit of service configuration to our project:

// /app/config/services.yml

services:
    audit_trail_listener:
        class: Symfony\Component\Workflow\EventListener\AuditTrailListener
        arguments:
            - "@logger"
        tags:
            - { name: kernel.event_subscriber }

There's nothing particularly special about the AuditTrailListener. It is a PHP class that implements EventSubscriberInterface, which is how we will inform the event dispatcher of which events we are interested in listening too.

We need to inject the logging service - in other words, Monolog - so we can write to our logs.

And we need to tag the service in order that Symfony will correctly find, use and configure this class when compiling the service container. This all sounds quite confusing, but essentially means that behind the scenes, Symfony will ensure the specified actions are called when specific events are dispatched.

For the geeky:

// /vendor/symfony/symfony/src/Symfony/Component/Workflow/EventListener/AuditTrailListener.php

    public static function getSubscribedEvents()
    {
        return array(
            'workflow.leave' => array('onLeave'),
            'workflow.transition' => array('onTransition'),
            'workflow.enter' => array('onEnter'),
        );
    }

Leads to the following:

// /var/cache/dev/appDevDebugProjectContainer.php

protected function getDebug_EventDispatcherService()
{
    $this->services['debug.event_dispatcher'] = $instance = new \Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher(new \Symfony\Component\EventDispatcher\ContainerAwareEventDispatcher($this), ${($_ = isset($this->services['debug.stopwatch']) ? $this->services['debug.stopwatch'] : $this->get('debug.stopwatch')) && false ?: '_'}, ${($_ = isset($this->services['monolog.logger.event']) ? $this->services['monolog.logger.event'] : $this->get('monolog.logger.event', ContainerInterface::NULL_ON_INVALID_REFERENCE)) && false ?: '_'});

    $instance->addListener('kernel.controller', /** @closure-proxy Symfony\Bundle\FrameworkBundle\DataCollector\RouterDataCollector::onKernelController */ function (\Symfony\Component\HttpKernel\Event\FilterControllerEvent $event) {
        return ${($_ = isset($this->services['data_collector.router']) ? $this->services['data_collector.router'] : $this->get('data_collector.router')) && false ?: '_'}->onKernelController($event);
    }, 0);

    $instance->addListener('workflow.leave', /** @closure-proxy Symfony\Component\Workflow\EventListener\AuditTrailListener::onLeave */ function (\Symfony\Component\Workflow\Event\Event $event) {
        return ${($_ = isset($this->services['audit_trail_listener']) ? $this->services['audit_trail_listener'] : $this->get('audit_trail_listener')) && false ?: '_'}->onLeave($event);
    }, 0);

    $instance->addListener('workflow.transition', /** @closure-proxy Symfony\Component\Workflow\EventListener\AuditTrailListener::onTransition */ function (\Symfony\Component\Workflow\Event\Event $event) {
        return ${($_ = isset($this->services['audit_trail_listener']) ? $this->services['audit_trail_listener'] : $this->get('audit_trail_listener')) && false ?: '_'}->onTransition($event);
    }, 0);

    $instance->addListener('workflow.enter', /** @closure-proxy Symfony\Component\Workflow\EventListener\AuditTrailListener::onEnter */ function (\Symfony\Component\Workflow\Event\Event $event) {
        return ${($_ = isset($this->services['audit_trail_listener']) ? $this->services['audit_trail_listener'] : $this->get('audit_trail_listener')) && false ?: '_'}->onEnter($event);
    }, 0);

Thankfully you really don't need to know that happens behind the scenes to listener and / or subscribe to events.

With this configured, the next time we apply a successful transition, we should see some extra output in the logs:

$ tail -f var/logs/dev.log

# extra stuff removed

[2017-02-23 13:35:50] doctrine.DEBUG: SELECT t0.id AS id_1, t0.username AS username_2, t0.password AS password_3, t0.age AS age_4, t0.country AS country_5, t0.marking AS marking_6 FROM customer t0 WHERE t0.username = ? LIMIT 1 ["vbn"] []
[2017-02-23 13:35:51] app.INFO: Leaving "free_customer" for subject of class "AppBundle\Entity\Customer". [] []
[2017-02-23 13:35:51] app.INFO: Transition "vip_approval" for subject of class "AppBundle\Entity\Customer". [] []
[2017-02-23 13:35:51] app.INFO: Entering "paying_customer" for subject of class "AppBundle\Entity\Customer". [] []
[2017-02-23 13:35:51] doctrine.DEBUG: "START TRANSACTION" [] []
[2017-02-23 13:35:51] doctrine.DEBUG: UPDATE customer SET marking = ? WHERE id = ? [{"paying_customer":1},4] []
[2017-02-23 13:35:51] doctrine.DEBUG: "COMMIT" [] []

And this is helpful. Now we see these INFO statements around Leaving, Transitioning, and Entering a particular place.

It can be more helpful to filter events pertaining to your app into their own log file, so as to remove all the extra noise:

# /app/config/config_dev.yml

monolog:
    handlers:
        workflow_log:
            type: stream
            path: "%kernel.logs_dir%/%kernel.environment%-workflow.log"
            level: debug
            channels: ['app']
        # other handlers here

Which would result in something like:

$ tail -f var/logs/dev-workflow.log

[2017-02-23 13:38:40] app.INFO: Leaving "free_customer" for subject of class "AppBundle\Entity\Customer". [] []
[2017-02-23 13:38:40] app.INFO: Transition "vip_approval" for subject of class "AppBundle\Entity\Customer". [] []
[2017-02-23 13:38:40] app.INFO: Entering "paying_customer" for subject of class "AppBundle\Entity\Customer". [] []

If you'd like to learn a few more tips and tricks for working with Monolog, I would recommend you watch this video.

Now, there is a whole bunch more to explore when working with events inside the Workflow Component. The AuditTrailListener only covers three of the 5 possible events - the other two being entered, and announce. Also, the audit trail listener captures global transitions, and we can be a lot more specific. All of this will be covered later in this series.

Code For This Course

Get the code for this course.

Episodes