Saturday, February 19, 2022

Notes on Symfony 5: The Fast Track

Symfony 5: The Fast Track (front cover)

With the development phase for the Sigala project just starting, I’ve been looking to facilitate the swift delivery of a functional proof of concept using a mature PHP framework with many basic components provided in a well-structured way.   Enter Symfony, which I first heard about in around 2015 whilst taking baby steps in MVC by adopting Twig templates with my own interpretation of models and controllers.  Then, last autumn, I dipped my toes in the water with an accessible YouTube tutorial  delivered by Mohammed Kaabi for freeCodeCamp.org

Now, to really see what it’s capable of and to bring me up to speed, I’ve worked my way through version 5 of Symfony: The Fast Track, the official Symfony book by Fabien Potencier, its founder, released under a Creative Commons CC BY-NC-SA 4.0 license through the generosity of supporters.  The sentiments expressed in the book’s introduction fit well with my intentions,

The book describes the creation of an application, from scratch to production. ... Best practices won't be respected all the time. But we are going to touch on almost every aspect of a modern Symfony project.

While starting to work on this book, the very first thing I did was code the final application. I was impressed with the result and the velocity I was able to sustain while adding features, with very little effort.

That ability to add in features quickly is just what I’m looking for as I try things out, somewhat like working with a maquette.  That's the theory.

So, in this post, I share personal notes and views from following the book in coding from start to finish a conference guestbook.  I make reference to version 5.2  which was originally released in February 2021 and the book’s latest version until ten days ago (9 February 2022) when it was updated to support the latest Symfony releases (5.4 and 6.0, as of writing).  Furthermore, it looks like the book is becoming core to the project; the updates to keep it current are welcome, particularly for 5.4, which comes with long-term support.  However, in my case it was too late to switch.  Actually, from a quick glance, there don’t appear to be that many changes so far – there is helpfully more explanatory text, but as far as the code is concerned, it’s mainly a case of updating it to work in the latest versions.
 
I’ve selected a few themes from the many that the book introduces:


Afterwards, I offer some evaluation.

(I’ll refer to the author as ‘Fabien’, which seems to be the norm; I trust that he doesn’t mind.) 

Project Setup

There is a lot to set up.  It’s not until the creation of entity classes in chapter 8, that we have some PHP coding!  This may be routine for a professional full-stack developer, but for a novice or someone who is more narrowly focused on programming it may be off-putting.  I’ve heard it said that if you write a book about mathematics for the general public then you can expect the readership to halve every time you introduce a new equation.  I think something similar may apply in any tutorial-style work about software development for newcomers where various tools and services are required.

Work Environment

The checklist for this book (which Fabien has naturally automated with a symfony command!) presents quite a high bar.  After installing a few packages and libraries, I managed to ‘tick the box’ for all the required components, though I didn’t stick with Docker, as I shall explain later.  Also, I immediately diverged on a few of the recommended items.  The main one was the database engine – I switched from PostgreSQL, which I last used in 2008, to MariaDB, as that (or MySQL) is what I have been habitually using for my projects.  And I think of PHP and MySQL as forming a harmonious relationship ever since Monty Widenius collaborated with Rasmus Lerdorf in the 1990s

The first time Widenius met a PHP developer was when he saw Rasmus Lerdorf speak at an open-source conference. When Lerdorf explained how persistent connections were handled, "I thought it was unnecessary to open a new connection for each user," said Widenius, so I asked him "If I added some changes to MySQL would it help?" When the answer was yes, Widenius modified MySQL and the problem was solved on both ends.
 
Even so, I realized that I was taking a risk as there are differences in SQL syntax and Postgres has some features that MySQL lacks.  In the event, whilst I needed to tweak some SQL statements, I didn’t observe any lack of required functionality.

For the IDE, I used NetBeans, which is free and well-established; I’ve used it for most of my recent projects.  My main grumble is that the IDE is unusable when it carries out the initial background scanning of projects that include many third-party libraries (as is the case here), but the benefit is that it subsequently knows where they all are and how to access them.  Some configuration is needed to deal with other performance issues – such as adding these libraries, together with var/ folder to the search exemptions.  Once dealt with, I found NetBeans worked well, but I’m aware that some other IDEs, including Visual Studio Code, offer more fluent working with PHP.

I did not try Symfony Cloud – whilst its promotion is understandable, as an individual learner I'm not much motivated to use it, especially as the free trial period is only 7 days.  However, for a company employee, it may well be appropriate.  Fortunately, its omission doesn’t obstruct the book’s flow.  Among the other deployment options, Deployer appeals to me more, but again, not necessary at this stage.

Docker and MariaDB

My only prior experience of Docker was to evaluate some machine vision software by Oxford University’s Visual Geometry group, which used it as a distribution mechanism.   Their software required a number of specialist components, which would be quite an effort to install one by one.  However, in this instance, to use Docker, essentially a Linux VM for a single database instance with hardly any data, seemed overkill; I guess my view is that it's 'horses for courses.'

Despite my reservations, I went along with Docker and set up an instance of MariaDB.  Fortunately, Docker Compose is obliging:

$ ./bin/console make:docker:database

This asks which database engine to use, including MariaDB, and then creates the docker-compose.yaml file accordingly.

For Postgres, there is a nice command where environment variables are picked up, so there’s no need to remember credentials or port number, but for MySQL or MariaDB support is yet to be added,  So, I set up manually by modifying doctrine-compose.yaml:


version: '3.7' services: database: image: 'mariadb:latest' environment: MYSQL_ROOT_PASSWORD: password MYSQL_DATABASE: main ports: - '3306:53074'


With port forwarding established, I could duly administer the database from my machine as though it were local.  I then configured phpMyAdmin to allow Web admin, by adding a few lines to config.inc.php:

/** * Second server */ $i++; /* Authentication type */ $cfg['Servers'][$i]['auth_type'] = 'cookie'; /* Server parameters */ $cfg['Servers'][$i]['host'] = '127.0.0.1'; $cfg['Servers'][$i]['port'] = '53074'; $cfg['Servers'][$i]['compress'] = false; $cfg['Servers'][$i]['AllowNoPassword'] = false;

The port, which is randomized, needs to be set each time Docker is restarted.  

Abandonment of Docker

Whilst on Chapter 14, I was no longer able to connect to the database instance on the Docker Container because the port forwarding changed and seemed to only allow internal access.  After trying a few things, I gave up trying to fix the problem, launched a CLI terminal from the Docker Dashboard and dumped the database to a file, 

# mysql -u root -p -h 127.0.0.1 main > /tmp/main.sql

I then copied the file from the container (guestbook_database_1) onto the local filesystem:

$ docker cp guestbook_database_1:/tmp/main.sql .

Then, I set up a fresh database locally and imported the dump.  Now that Docker files wouldn’t be used, I edited env.local to include (on one line):

DATABASE_URL="mysql://fasttrack:password@127.0.0.1:3306/ guestbook?serverVersion=10.6.4-MariaDB - Homebrew"
  
From that point on, I happily continued with the local MariaDB instance.

SQL Tweaks

With much of the SQL being created through the Symfony MakerBundle, I didn’t have to make many changes to the SQL.  However, there were usually tweaks to be made wherever Fabien had manually modified [Postgres] SQL after running make:migration.  

13.2: Adding Slugs to Conferences

Manual changes to deal with the issue of existing entries getting a null value.

    $this->addSql('ALTER TABLE conference ADD slug VARCHAR(255)');
    $this->addSql("UPDATE conference SET slug=CONCAT(LOWER(city), '-', year)");
    $this->addSql('ALTER TABLE conference MODIFY slug VARCHAR(255) NOT NULL');

15.3 Creating an Admin

Rather than run a fairly long query at the command line, I used phpMyAdmin.  Log in and select the guestbook database and then the admin table.  Select the [SQL] tab along the top and then [INSERT] tab under the text area window.  It automatically generates a sample that includes id, but since it is auto-incrementing, we don’t need to define it.  Thus, we can remove that field and copy & paste in our encoded password that was generated from the command line, something like:

INSERT INTO `admin`(`username`, `roles`, `password`) VALUES ('admin','["ROLE_ADMIN"]','$argon2id$v=19$m=65536,t=4,p=<hash>')

18.1 Flagging Comments 

This section introduces a state for comments: submitted, spam, and published and adds the state property to the Comment class.  For MySQL, the SQL for setting all comments to have the default published:

+ $this->addSql(ALTER TABLE comment MODIFY state VARCHAR(255) NOT NULL ');

18.5 Going Async for Real 

This is just an observation, no SQL code changes.   Fabien remarks: “Behind the scenes, Symfony uses the PostgreSQL builtin, performant, scalable, and transactional pub/sub system (LISTEN/NOTIFY).”  That sounds like a sales pitch, perhaps USP, for PostgreSQL; a quick lookup seems to show that MariaDB doesn’t have an equivalent to LISTEN/NOTIFY.   I don’t really know the significance.  All I can say is that I didn’t notice any issues in database transactions (messages seemed to get passed between application and database as expected).

Code

I’m not well-qualified to judge here (I’ve never done code reviews for others), but Fabien's code seems polished, concise and precise.  Apart from the book’s narrative, it’s generally uncommented.  So, as a relative newcomer to Symfony, I found myself having to look up various sources for elucidation.  Fortunately, the documentation provides excellent coverage and the video tutorials provide very helpful explanation.

I encountered some issues as I went along, some of which were probably due to my not following instructions correctly, but I’ll mention the main ones I had in case others encounter the same problems.

Dependency Issues

I had a few of these along the way, both in composer and yarn.  The first arose when installing the profiler:

$ symfony composer req profiler --dev Warning from https://flex.symfony.com/versions.json: You are using an outdated version of Flex, please run: composer update symfony/flex Using version ^1.0 for symfony/profiler-pack

To solve this, I ended up modifying composer.json, replacing “5.2.*” with:

"require-dev": { "symfony/stopwatch": ">=5.2", "symfony/web-profiler-bundle": "5.2.*" },

(I should have compared by checking out from the official repo.)

However, I wasn’t clear about what I was doing.  So, it would be helpful to have some guidance on how to deal with these issues – I’m sure that with experience they are resolved much more easily.

Not null constraint on Creating Comments

In Chapter 9, just before the section ‘Customizing EasyAdmin’, the text invites the reader to add some comments.  But it’s not yet possible because there’s no reference to conferences in the user interface.  When trying to submit a comment by hitting the [Create] button we get an error: 

An exception occurred while executing a query: SQLSTATE[23000]: Integrity constraint violation: 1048 Column 'conference_id' cannot be null.

From the profiler ‘Logs’ tab, we can see that Doctrine tried to insert a null value into this field, but a not null condition is specified in the Comment entity:

/**
  * @ORM\ManyToOne(targetEntity=Conference::class, inversedBy="comments")
  * @ORM\JoinColumn(nullable=false)
  */

    private $conference;

This condition was originally set when we ran make:entity for the conference field.

To take account of the Conference, we need some mechanism to link the comment with the relevant conference and that should be made by the person writing the comment.   The configureFields() method in CommentCrudController.php is amenable and EasyAdmin can list the conferences in a drop-down.  The following solution prepends the conference column to the configureFields() method:

$fields = parent::configureFields($pageName);
        array_unshift($fields, AssociationField::new('conference'));
        return $fields;
)
        
The issue was already reported and this is just a variant of the solution offered there, though I don’t yet see it in the latest edition of the book.

Coding Variations

In a few cases, I used slightly different code to achieve the same result. For example, in Chapter 10.3: Using Twig in a Controller, I drew on Kaabi’s tutorial for a simpler rendering of the template in the index() function.  In the case of extending AbstractController, we don’t need to explicitly reference Twig.  We can just refer to the instance of the conferenceRepository, using a technique explained in the documentation.

class ConferenceController extends AbstractController {

    #[Route('/', name: 'homepage')]
    public function index(ConferenceRepository $conferenceRepository): Response {
        return new Response($this->render('conference/index.html.twig', [
                    'conferences' => $conferenceRepository->findAll(),
        ])->getContent());
    }

(The getContent() method just returns the content of a response, an alternative to using RenderView().)

Workflows: State Machines

The treatment of workflows, particularly the section on their description, sparked my interest in its direct connection with state machines and their formal representation in Petri Nets.  When using formal description techniques, we can take advantage of mathematical methods that add rigour to tasks of verification (that processes are behaving correctly) and validation (that we are designing the right processes).  In my PhD, I used process algebra (LOTOS) and temporal logic as the formalisms to study safety-critical systems.  My present interest is in [human] safety properties in the design of social media – not just protection from physical harm, but mental harm.  Taking a Buddhist model, this is protection across the khandhas – form (body) together with feelings, perceptions, mental formations and consciousness (mind).  It's a topic that requires a separate blog post.

User Notifications

On a practical note, workflows underpin user notifications, where we have the one instance where (in 25.5) the reader is invited to write their own code.  As the last task of the main website construction, users are to be notified when their submission is approved. There are probably many ways to achieve this.  I’ll share an attempt here using a workflow-oriented method.

This first step is to create a template with a basic acceptance message, comment_accepted.html.twig

{% extends '@email/default/notification/body.html.twig' %}

{% block content %}
    <p>Dear {{ comment.author }},</p>
    <p>Your comment (copied below) has been accepted!</p>
    <p>
        {{ comment.text }}
    </p>
    <p>Thank you for your contribution.</p>
{% endblock %}

Thus far, notifications have been used only for admins.  The message is sent via:

$this->notifier->send(new CommentReviewNotification($comment), ...$this->notifier->getAdminRecipients());

The second parameter of the send() method has to be of type Symfony\Component\Notifier\Recipient\RecipientInterface

We have to interact with a class that implements RecipientInterface – directly or as an extension, etc., or possibly create such an implementation or extension ourselves.  Symfony’s documentation on Notifiers helpfully gives us the answer in the section on creating & sending notifications.

In InvoiceController.php, the key line is:

   $notifier->send($notification, $recipient);

So, in CommentMessageHandler.php we include the Recipient class, 

use Symfony\Component\Notifier\Recipient\Recipient;

And instead of ...$this->notifier->getAdminRecipients() use $comment->getEmail() for the email address submitted in the comment.  Then we might we anticipate defining a new class, CommentAcceptedNotification, to return a different message output.   

However, instead of having separate classes for different comment-related notifications, we can fold the code into one, by augmenting CommentReviewNotification with further methods and properties.

So, immediately after 

$this->workflow->apply($comment,'optimize');

insert:

$this->notifier->send(new CommentAcceptedNotification($comment), new Recipient($comment->getEmail()));

Furthermore, we can determine what notifications to send based on the workflow, which has been defined to support comments.  We do this by reviewing config/packages/workflow.yaml and its corresponding state transition diagram:


State transition diagram for complete workflow (Graphviz output)


We can choose rules based on places and/or transitions.  The book has based rules only on transitions, but here I think it’s better to start with places, which are unique, whereas in general transitions might not be so.  

For this exercise, we just want to add the case where the place (state) = published.

The getters and setters are already there in App\Entity\Comment.  

/**
  * @ORM\Column(type="string", length=255, options={"default": "submitted"})
  */

    private $state = 'submitted';
    
and

    public function getState(): ?string
    {
        return $this->state;
    }

    public function setState(?string $state): self
    {
        $this->state = $state;
        return $this;
    }

So, we update CommentReviewNotification to determine the current place with

$place = $this->comment->getState();

And add a map from states to templates.  Thus we ended up with the following for the  class CommentReviewNotification


class CommentReviewNotification extends Notification implements EmailNotificationInterface
{
    private $comment;
    private $place;
    public const NOTIFICATION_TEMPLATE_MAP = [
            'potential_spam' => 'emails/comment_notification.html.twig',
            'ham' => 'emails/comment_notification.html.twig',
            'published' => 'emails/comment_accepted.html.twig'
        ];

    public function __construct(Comment $comment)
    {
        $this->comment = $comment;

        parent::__construct('New comment posted');
    }

    public function asEmailMessage(EmailRecipientInterface $recipient, string $transport = null): ?EmailMessage
    {
        $place = $this->comment->getState();
        if (isset(self::NOTIFICATION_TEMPLATE_MAP[$place])) {
            $notification_template = self::NOTIFICATION_TEMPLATE_MAP[$place];
        } else {
            $notification_template = 'emails/comment_notification.html.twig'// should be a different template to cover other states (or we're lost!)
        }
        
        $message = EmailMessage::fromNotification($this, $recipient, $transport);
        $message->getMessage()
                ->htmlTemplate($notification_template)
                ->context(['comment' => $this->comment])
                ->importance(Notification::IMPORTANCE_LOW)
        ;
        return $message;
    }

 ... 

}

Whilst quite a neat solution, I think it would be more consistent to put this logic in CommentMessageHandler.php, along with the conditionals for transitions, where, again, I think using places might be better.

User Experience

In more recent projects, I’ve observed a continual shift towards the frontend, to user experience.  Mindful of its importance, what do we observe from this book?  Not so much.  The treatment is a bit lightweight.

User Interface

Whereas Kaabi introduced Bootstrap almost immediately after outputting just the main heading in a template, here, it’s not until chapter 22, after coding much of the application logic, that Fabien shows how to style the user interface.  There’s no discussion of how the templates have been designed – you just download them and are encouraged to “Have a look at the templates, you might learn a trick or two about Twig.”  That’s it!

For Symfony, I can see why it’s not a high priority – what’s of greater relevance for a framework like this is the way integration is performed in Webpack.  I sympathise with this view, but I think the book could be quite a bit more popular if greater attention were given to this area.  And the information I seek might be readily available – I would have particularly welcomed more pointers to illustrate the work of the UX initiative  (see also its rationale).  As it stands, it’s probably where I feel there are the most question marks.  

At least, with the SPA, styling was introduced before data was fetched. 

Incidentally, in the text encountered few display issues, e.g. in creating the SPA main template, my browser does not render the code listing for src/app.js correctly.  The <div> tag around ‘Hello world!’ has disappeared and the code to render should be:

render(<App />, document.getElementById('app'));

Internationalization (i18n)

I’m glad this section was included, not least because it helped clarify a standard approach to delivering localized content.  Previously, I using Gettext for a bilingual (English LTR/Arabic RTL) collections intranet for the Qatar Museums Authority, with the ability to toggle the locale on any page.  Most of the work for the translations was carried out by a Syrian colleague, who translated every inventory field that could be displayed. 

In the conference guestbook, I added a Thai version (notice the months notation in the date):



As it stands, entering translations for RTL languages will not change the direction of page contents.   To quickly (and lazily) see the effect, I generated a translation file for Arabic with the following additional translation unit:

      <trans-unit id="7654321" resname="LTR">
        <source>LTR</source>
        <target>RTL</target>
      </trans-unit>

Then edited the <html> tag in base.html.twig:

<html lang="{{ app.request.locale }}" dir="{{ 'LTR'|trans }}">

The result is:


It makes the key adjustment for the page as a whole, but some blocks are still styled left-to-right.  

It would also be useful to indicate how to implement i18n on the SPA. 

One further remark, about providing the book itself in different languages.  Up until this version, 5.2, it was available in more than a dozen languages, including Arabic.  It's evidently been a huge amount of work and going forward will be hard to maintain and so far I see only European languages for the current versions.  But perhaps automated translation is now mature enough to facilitate?  Whereas 10-15 years ago, the results could be a bit comical, they have in recent years improved considerably.  Symfony provides integration with third-party providers (perhaps DeepL be added to the list?) which can offer a very useful starting point.  Furthermore, the language of programming is probably more consistent than the average text.

Pondering Deployment

When to use?  At first glance, it seems natural to just deliver website.com/fr/ to French-language speakers or website.com/es-AR/ to inhabitants of Argentina.  However, it is not always appropriate to view a website in your own locale if, for example, you are learning another language and want to read local news in other countries.  Many people are of mixed ethnicity and multilingual – it is quite common for children of immigrant families to speak the language of the host country at school, but their mother tongue at home.  This suggests there should be flexibility in switching between languages.  

This seems to be tacitly recognised on the Web when you first visit a site and get a popup saying ‘You are viewing this page in <locale>, but [based on your IP address] you are viewing from <country Y>.  Do you want to view in your own locale?  More generally, I’d like to push beyond the mechanical translations to cultural translations – how to more authentically support online the process of introducing oneself, which can vary considerably between cultures?   

Evaluation

The book is well-written and conveys convincingly the tremendous achievement of the Symfony project.  It’s really a showcase rather than a tutorial, yet is very instructive.  With its lean and concise descriptions, the book covers an extraordinary amount of ground – the order in which new material is introduced builds on previous matter and leaves nothing to waste. 

It took me weeks to work my way through, but a lot of that is down to my lack of familiarity with various components that many will already be using on a regular basis, including third-party services such as Akismet and Slack (I think using the API unlocked the 30-day trial of premium features).  I feel it’s worth the effort, especially as I’ve learnt a lot about the framework’s potential with useful pointers about implementation, which is the main reason I took on the book in the first place.  It has considerable capacity to support the development of complex applications in an efficient and manageable way, leaving me with the impression that this really is a robust platform.

Somewhat unusually for an introductory book to a language or framework, it provides significant coverage of topics relating to production systems – their monitoring and efficient delivery.  I’m not yet at the stage where I need to concern myself with these matters, but this ‘systems thinking’ shows foresight and experience of running systems for real; it’s very welcome.  Furthermore, they are placed in the text in such a way that much of the material can be skipped (I have not yet read through the last three chapters).  Whilst I originally decided to use Symfony for its provision of many components, I have been greatly impressed by what I’ve seen, so I think it is a serious candidate for delivering the software at every stage, right through to production and ‘business as usual’.

Whilst I followed the instructions from the Website, I purchased a printed (PDF) version to support the work (and I did enjoy the family artwork!).  One suggestion.  In return for payment, it would have been nice to have added a sample month’s subscription to symfonycasts.com, to support further in-depth exploration.

Fabien continues to be very active in Symfony’s further development and promotion (see, for example, his responses in a live Q&A from Feb.2019.  The community supporting the project also appears well-engaged, which all bodes well for the future.






No comments: