escape

RUN..


DISCLAIMER:I DO NOT pretend to expose a best practice, as Symfony core developers are the eligible ones to do this. There are a couple of solutions to deal with Symfony Form tough couplings, such as  Form EventsData Transformers or even this solution presented by @matthiasnoback.

I DO ONLY share a solution I found useful for my self, it might be the least suitable one, and I’m all ears for contributions and suggestions.


 

Hello and welcome back to the last part, Part 4,  of the  “Escape the Symfony Form jail” series.

(Please refer to Part 1 ,  Part 2 & Part3 to get into the context.)

In this part, we’re going to do the real runaway from the Symfony Form Component Jail :).

The TeamCommand Entity

Generally speaking, the command programming pattern solves the problem of coupling a function with a given action. Instead, it allows you to encapsulate a given behavior into an object, which you can then pass to the function that is associated with some action..

Our Team command will be nothing but a simple PHP class, with keys and teams properties.

It is instantiated by passing a teams argument. This should be an array of Team objects, representing all existent Teams in the database. The constructor will transform this array to an associative array of id=>(true/false) pairs, and make it the teams property of the TeamCommand Class.

This teams property is a handy way to represent which Team objects are up-voted or not-up-voted by a certain user.

The keys property will simply hold all existent team ids, for any eventual need (looping, etc..)

Notice that the user is omitted here. The command is exclusively used to generate the executor’s entity (Team). It is at the Team entity’s level where we are allowed to talk about the user (up-voter).

So here is what our TeamCommand Entity looks like:

src/AppBundle/Entity/TeamCommand.php

<?php
// src/AppBundle/Entity/TeamCommand.php
namespace AppBundle\Entity;
 
class TeamCommand
{
    /**
     *  A key(index)/value(true or false) array
     * @var array
     */
    private $teams;
    /**
    *  Array of all existent indexes of teams
    * @var array
    */
    private $keys;
    /**
     * constructs a TeamCommand object from an array of Team objects
     * @param array $teams
     */
    public function __construct($teams=null)
    {
        // Initialize properties
        $this->teams=array();
        $this->keys=array();
        // null parameter handling
        if ($teams==null) {
            return null;
        }
        // Build keys from given teams
        $this->keys = array_map(function ($t) {
            return $t->getId();
        }, $teams);
        // Initialize all teams to false (no upvotes)
        foreach($this->keys as $key){
            $this->teams[$key]=false;
        }
    }
    /**
     * @param array $keys
     * @return   array
     */
    public function getKeys()
    {
        return $this->keys;
    }
    /**
     * @param array $keys
     * @return   array
     */
    public function setKeys($keys)
    {
        $this->keys=$keys;
        return $this;
    }
    /**
     * @return array
     */
    public function getTeams()
    {
        return $this->teams;
    }
    /**
     * @return array
     */
    public function setTeams($teams)
    {
        $this->teams=$teams;
        return $this;
    }
    /**
     * Set teams with the indexes of the $keys parameter to true
     * @param array $keys
     * @return   TeamCommand
     */
    public function enableTeams($keys)
    {
        foreach ($keys as $key) {
            $this->teams[$key]=true;
        }
        return $this;
    }
    /**
     * Set teams with the indexes of the $keys parameter to false
     * @param array $keys
     * @return   TeamCommand
     */
    public function disableTeams($keys)
    {
        foreach ($keys as $key) {
            $this->teams[$key]=false;
        }
        return $this;
    }
}

Executing such a command will be as easy as writing this simple function inside the Team Repository Class:

src/AppBundle/Repository/TeamRepository.php
/**
 * Builds an array of Team objects given a TeamCommand object
 *
 * @param TeamCommand $teamCommand
 * @return array [AppBundle\Entity\Team]
 */
public function executeCommand(TeamCommand $teamCommand)
{
    $teams = array();
    $commands=$teamCommand->getTeams();
    foreach ($commands as $key => $value) {
        if ($value==true) {
            $teams[] = $this->findOneById($key);
        }
    }
    return $teams;
}

The User (up-voter) Entity

The User Entity will also need some handy methods to add, remove, clear and set up-voted teams depending on given data (Team objects)

src/AppBundle/Entity/User.php

<?php
// src/AppBundle/Entity/User.php

namespace AppBundle\Entity;

use FOS\UserBundle\Model\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;

/**
 * @ORM\Entity
 * @ORM\Table(name="fos_user")
 */
class User extends BaseUser
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    public function __construct()
    {
        parent::__construct();
        $this->upvotedTeams = new ArrayCollection();
    }

    /**
     * @ORM\ManyToMany(targetEntity="Team",mappedBy="upvoters",cascade={"persist"})
     * @ORM\JoinColumn(nullable=true)
     */
    private $upvotedTeams;
   /**
     * Add upvotedTeam
     *
     * @param \AppBundle\Entity\Team $upvotedTeam
     *
     * @return User
     */
    public function addUpvotedTeam(\AppBundle\Entity\Team $upvotedTeam)
    {
        // Very important, don't miss this line!
        $upvotedTeam->addUpvoter($this);
        $this->upvotedTeams[] = $upvotedTeam;

        return $this;
    }

    /**
     * Remove upvotedTeam
     *
     * @param \AppBundle\Entity\Team $upvotedTeam
     */
    public function removeUpvotedTeam(\AppBundle\Entity\Team $upvotedTeam)
    {
         // Very important, don't miss this line!
        $upvotedTeam->removeUpvoter($this);
        $this->upvotedTeams->removeElement($upvotedTeam);
    }

    /**
     * Get upvotedTeams
     *
     * @return \Doctrine\Common\Collections\Collection
     */
    public function getUpvotedTeams()
    {
        return $this->upvotedTeams;
    }
     
    /**
     * Add multiple upvotedTeams
     *
     * @param Team[] $upvotedTeams
     *
     * @return User
     */
    public function addUpvotedTeams($upvotedTeams)
    {
        if(empty($upvotedTeams)){
            return $this;
        }
        foreach ($upvotedTeams as $upvotedTeam){
            $this->addUpvotedTeam($upvotedTeam);
        }

        return $this;
    }
    /**
     * remove all upvotedTeams
     *
     * @return User
     */
    public function clearUpvotedTeams()
    {
        if(empty($this->upvotedTeams)){
            return $this;
        }
        foreach ($this->upvotedTeams as $upvotedTeam) {
            // Very important, don't miss this line!
            $upvotedTeam->removeUpvoter($this);
            $this->removeUpvotedTeam($upvotedTeam);
        }
        return $this;
    }
    /**
     * Set multiple upvotedTeams
     *
     * @param Team[] $upvotedTeams
     *
     * @return User
     */
    public function setUpvotedTeams($upvotedTeams)
    {
        $this->clearUpvotedteams();
        $this->addUpvotedTeams($upvotedTeams);
        return $this;
    }
}

We are now two steps away from getting our application up and running: creating the form and writing the templates.

The TeamCommand form

So the workaround will consist of writing a TeamCommandType form which is a collection of CheckboxType.

The form will fill a TeamCommand entity, specially its teams property. Remember that this property is an array of id=>(true/false) keys. Once we gather these “choices”, we pass this TeamCommand the The Team repository which executes it and gives us back an array of up-voted teams.

Here is what the TeamCommandType form consists of:

src/AppBundle/Form/TeamCommandType.php

<?php
// src/AppBundle/Form/TeamCommandType.php
namespace AppBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use AppBundle\Entity\TeamCommand;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;

class TeamCommandType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder ->add('teams', CollectionType::class, array(
            'label'=>' ',
            'entry_type'   =>  CheckboxType::class,
            'entry_options'=> array(
                'required' =>false,
                'label'=>' ',
                'attr'=>array('class'=>'ui checkbox'),
             )))
            ->add('submit', SubmitType::class, array('label' => 'Update'));
    }
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => TeamCommand::class,
        ));
    }
}

 

The Team Controller

Now time for the coordinator, the maestro of the game: the Controller!

The TeamController class should include an action responsible of the following steps:

  1. Instantiate  a TeamCommand
  2. Set its teams property  to the current user’s teams
  3. Pass it to a newly created TeamCommandType Form
  4. Execute the submitted data/command by sending it to the TeamRepository receiving an array of Team objects
  5. Set this list of Team objects as the up-voted teams of the current User
  6. Persist the current User!

Let’s translate this to some code:

src/AppBundle\Controller\TeamController.php

<?php
// src/AppBundle\Controller\TeamController.php
namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
use AppBundle\Entity\User;
use AppBundle\Entity\TeamCommand;
use AppBundle\Form\TeamCommandType;

class TeamController extends Controller
{
    /**
     * @Route("/upvote", name="upvote", options={"expose"=true})
     */
    public function editAssemblyAction(Request $request)
    {
        // Get the EntityManager
        $em = $this->getDoctrine()->getManager();
        // Get the current user
        $user = $this->getUser();
        // Get the list of all existent teams
        $allTeams = $em->getRepository("AppBundle:Team")->findAll();
        // Extract indexes (id) of all existent teams;
        $keys = array_map(function($t){return $t->getId();},$user->getUpvotedTeams()->toArray());
        // Create a team command and initialize it to the current user's teams (upvotedTeams)
        $teamCommand = (new TeamCommand($allTeams))->enableTeams($keys);
        // Create a TeamCommandType form
        $form = $this->createForm(TeamCommandType::class, $teamCommand);
        // Wait for form request
        $form->handleRequest($request);
        // If the form is correctly submitted
        if ($form->isSubmitted() && $form->isValid())
        {
            // Get the submitted data (TeamCommand object)
            $teamCommand = $form->getData();
            // Execute the command, it should return an array of Team objects: upvoted teams!
            $teams = $em->getRepository("AppBundle:Team")->executeCommand($teamCommand);
            // Update the user's upvoted teams
            $user->setUpVotedTeams($teams);
            // Persist the user object
            $em->persist($user);
            // Flush and write out
            $em->flush();
            // Redirect to home page, to see the updated top 10 list.
            return $this->redirectToRoute('homepage');
        }
         // If the form is not submitted yet, or has errors, render the upvoting view
        return $this->render('upvote.html.twig',
            array(
                "user"=>$user,
                "allTeams"=>$allTeams,
                "form" => $form->createView(),
            ));
    }
}

 

The up-voting view

We will benefit of SemanticUI’s cards to render the up-voting form view.

The idea is to loop through the allTeams variable passed from the Controller. (We need this array of Team objects and not a simple TeamCommand because of the text and country properties that are omitted in the lightened command). During the loop, we use a counter to access the corresponding team field and read its value (true/false)

Twig is, as usual, so  great at rendering templates in an eloquent way:

app/Resources/views/layouts/main.html.twig

{% extends 'layouts/main.html.twig' %}
{% block layout_main_content %}
    {{ form_start(form) }}
    <h1>Upvote your favorite teams</h1>
    {% set i = 1 %}
    <div class="ui grid container">
    {% for team in allTeams %}
        <div class="four wide column">
            <div class="ui cards">
                <div class="card">
                    <div class="content">
                        {% set isUpvoted = form.teams[i].vars.data %}
                        {{('<i class="right floated '~ (isUpvoted?'red like icon ':'') ~ '"></i>')|raw}}
                        <div class="header">{{team.text}}</div>
                            <div class="description">
                                {{team.country.text}}
                            </div>
                        </div>
                        <div class="extra content">
                            {{form_widget(form.teams[i])}}
                            Upvote!
                        </div>
                </div>
            </div>
        </div>
        {% set i = i + 1 %}
    {% endfor %}
    </div>
    <div class="ui grid container">
        {{form_widget(form.submit,{"attr":{"class":"ui primary button"}}) }}
    </div>
    {{ form_end(form) }}
{% endblock layout_main_content %}

We now have all blocks wired together. Our up-voting form is something like this:

upvoting-form

The up-voting form

Whoa!

Bonus1:

You can get the entire source on Github, fork, commit, star and send issues from here

Bonus2:

You can also see the Top Soccer Teams application in action from here

Final words

It has been a very nice ride to share a personal experience with you. Keep in touch and stay tuned.

I would be glad if you reach me out at contact@medunes.net

goodbye